From 883b1dc82485c87f995c35a20929eec17d6030a5 Mon Sep 17 00:00:00 2001 From: yilsem Date: Tue, 13 Oct 2026 01:07:58 +0300 Subject: [PATCH] feat(settings): configure Jev evaluation and model transitions --- apps/desktop/assets/codex-bridge/bridge.py | 2 +- .../codex-bridge/jev-settings-defaults.json | 22 + .../assets/codex-bridge/jev_denetim.py | 80 ++- .../assets/codex-bridge/jev_settings.py | 89 ++++ apps/desktop/assets/codex-bridge/kopru.py | 45 +- .../assets/codex-bridge/test_jev_denetim.py | 5 +- .../assets/codex-bridge/test_jev_settings.py | 111 +++++ apps/desktop/electron/codex-link-config.ts | 2 +- apps/desktop/electron/main.ts | 44 ++ apps/desktop/electron/preload.ts | 2 + .../electron/staged-transition-types.ts | 27 + .../electron/staged-transition.test.ts | 53 ++ apps/desktop/electron/staged-transition.ts | 174 +++++++ apps/desktop/package.json | 1 + apps/desktop/src/app/settings/index.tsx | 30 +- .../staged-transition-settings.test.tsx | 71 +++ .../settings/staged-transition-settings.tsx | 463 ++++++++++++++++++ apps/desktop/src/app/settings/types.ts | 1 + apps/desktop/src/global.d.ts | 3 + apps/desktop/tsconfig.electron.json | 7 +- 20 files changed, 1195 insertions(+), 37 deletions(-) create mode 100644 apps/desktop/assets/codex-bridge/jev-settings-defaults.json create mode 100644 apps/desktop/assets/codex-bridge/jev_settings.py create mode 100644 apps/desktop/assets/codex-bridge/test_jev_settings.py create mode 100644 apps/desktop/electron/staged-transition-types.ts create mode 100644 apps/desktop/electron/staged-transition.test.ts create mode 100644 apps/desktop/electron/staged-transition.ts create mode 100644 apps/desktop/src/app/settings/staged-transition-settings.test.tsx create mode 100644 apps/desktop/src/app/settings/staged-transition-settings.tsx diff --git a/apps/desktop/assets/codex-bridge/bridge.py b/apps/desktop/assets/codex-bridge/bridge.py index bfedffa..394cc19 100644 --- a/apps/desktop/assets/codex-bridge/bridge.py +++ b/apps/desktop/assets/codex-bridge/bridge.py @@ -34,7 +34,7 @@ import urllib.request import gecis_context HERE = os.path.dirname(os.path.abspath(__file__)) -SURUM = "11" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION) +SURUM = "12" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION) try: with open(os.path.abspath(__file__), "rb") as _f: KOD = hashlib.sha256(_f.read()).hexdigest()[:8] diff --git a/apps/desktop/assets/codex-bridge/jev-settings-defaults.json b/apps/desktop/assets/codex-bridge/jev-settings-defaults.json new file mode 100644 index 0000000..71a7f47 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/jev-settings-defaults.json @@ -0,0 +1,22 @@ +{ + "enabled": true, + "endpoint": "https://api.typesafe.ai/v1/systemone", + "model": "jev-1.13.0", + "fallbackModel": "Katman-1", + "rejectionRules": [ + { "from": "Katman-1", "to": "Katman-2" }, + { "from": "Katman-2", "to": "Katman-3" }, + { "from": "Katman-3", "to": "Katman-4" } + ], + "exemptModels": ["Sesli-Hizli-Zeki", "Katman-4"], + "thresholds": { "ret": 0.5, "sart": 0.7 }, + "questions": { + "ret": "The assistant did NOT do what the user asked or did NOT answer the user's question: it refused, declined, said it cannot or will not, avoided giving the requested judgment, or redirected to a different topic.", + "sart": "The assistant changed the request on its own: it added its own conditions, softened or relabeled what was asked, or left part of it out, instead of doing exactly what the user asked." + }, + "timeoutSeconds": 3600, + "criteria": { "ret": { "true": "", "false": "" }, "sart": { "true": "", "false": "" } }, + "maxConcurrent": 0, + "queueWaitSeconds": 3600, + "failureAction": "pass" +} diff --git a/apps/desktop/assets/codex-bridge/jev_denetim.py b/apps/desktop/assets/codex-bridge/jev_denetim.py index 7b44101..008c929 100644 --- a/apps/desktop/assets/codex-bridge/jev_denetim.py +++ b/apps/desktop/assets/codex-bridge/jev_denetim.py @@ -18,6 +18,7 @@ import json import os import re import urllib.request +import jev_settings ADRES = "https://api.typesafe.ai/v1/systemone" MODEL = "jev-1.13.0" @@ -32,26 +33,44 @@ SORU = { _ANAHTAR = {"okundu": False, "deger": ""} def muaf_mi(hedef): - """12 Ekim 2026: yalnız çözülmüş exact sesli combo hedefi Jev'den muaftır. + """Settings' always-pass list matches exact resolved model IDs. Taşıma hatalarının katman sırasını değiştirmez; yedek model kendi adına denetlenir. """ - return hedef == "Sesli-Hizli-Zeki" + return hedef in jev_settings.read()[0]['exemptModels'] def sonrakiler(hedef): - """Jev gecirmezse sirayla denenecek katmanlar. Secili model bir katmansa ondan sonrakiler; katman degilse - butun katmanlar bastan (once Katman-1). Son katman (Katman-4) kendisi gelince bos doner: sorgulanmadan gecer.""" + """Model-specific rejection edges take precedence over the general fallback. + + Stop at an always-pass model or before revisiting any target. + """ + settings = jev_settings.read()[0] ad = str(hedef or "") - if not ad or ad == KATMAN[-1]: + if not settings['enabled'] or not ad: return [] - if ad in KATMAN: - return list(KATMAN[KATMAN.index(ad) + 1:]) - return list(KATMAN) + routes = {r['from']: r['to'] for r in settings['rejectionRules']} + result, seen = [], {ad} + # An exempt model can still use the general fallback on transport failure. + # Repeated targets stop the walk even if a file was edited outside Settings. + while len(result) < 65: + target = routes.get(ad, settings['fallbackModel']) + if target in seen: + break + seen.add(target) + result.append(target) + if target in settings['exemptModels']: + break + ad = target + return result def anahtar(ortam=None, dosyalar=None): """TypeSafe anahtari: ortam degiskeni, sonra ~/.typesafe/typesafe.env. Yoksa "".""" + if ortam is None and dosyalar is None: + saved = jev_settings.key() + if saved: + return saved ortam = os.environ if ortam is None else ortam deger = str(ortam.get("TYPESAFE_API_KEY") or "").strip() if deger: @@ -147,24 +166,53 @@ def komutlar(govde): return "\n".join(satirlar) -def _gonder(govde, key): - istek = urllib.request.Request(ADRES, data=json.dumps(govde).encode("utf-8"), method="POST", +def _gonder(govde, key, settings=None): + settings = settings or jev_settings.read()[0] + istek = urllib.request.Request(settings['endpoint'], data=json.dumps(govde).encode("utf-8"), method="POST", headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"}) - with urllib.request.urlopen(istek) as yanit: + with urllib.request.urlopen(istek, timeout=settings['timeoutSeconds']) as yanit: return json.load(yanit) +class Puan(dict): + """Carry the thresholds used for this evaluation through a settings change.""" + pass + + def sor(istek, cevap, key, gonder=_gonder): """Jev'e sorar. Donus: {"ret", "sart"} olasiliklari | None (sorulamadi).""" - govde = {"model": MODEL, "state": {"user_request": istek, "assistant_reply": cevap}, - "questions": {k: {"type": "noul", "instructions": v} for k, v in SORU.items()}} + settings = jev_settings.read()[0] + govde = {"model": settings['model'], "state": {"user_request": istek, "assistant_reply": cevap}, + "questions": {k: {"type": "noul", "instructions": v} for k, v in settings['questions'].items()}} + for name, criteria in settings.get('criteria', {}).items(): + values = {k: v for k, v in criteria.items() if v.strip()} + if values: + govde['questions'][name]['criteria'] = values + if not jev_settings.acquire(settings): + return None try: - yanitlar = (gonder(govde, key) or {}).get("answers") or {} - return {k: float(yanitlar[k]["noul"]) for k in SORU} + response = gonder(govde, key, settings) if gonder is _gonder else gonder(govde, key) + yanitlar = (response or {}).get("answers") or {} + result = Puan({k: float(yanitlar[k]["noul"]) for k in SORU}) + if any(not 0 <= v <= 1 for v in result.values()): + return None + result.thresholds = settings['thresholds'] + return result except Exception: return None + finally: + jev_settings.release() def karar(puan): """True: adim gecmez, sonraki katman yapar. Istisna yok.""" - return puan["ret"] >= ESIK["ret"] or puan["sart"] >= ESIK["sart"] + thresholds = getattr(puan, 'thresholds', None) or jev_settings.read()[0]['thresholds'] + return puan["ret"] >= thresholds["ret"] or puan["sart"] >= thresholds["sart"] + + +def hata_gecis(): + return jev_settings.read()[0]['failureAction'] == 'next' + + +def etkin_mi(): + return bool(jev_settings.read()[0]['enabled']) diff --git a/apps/desktop/assets/codex-bridge/jev_settings.py b/apps/desktop/assets/codex-bridge/jev_settings.py new file mode 100644 index 0000000..10c88e1 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/jev_settings.py @@ -0,0 +1,89 @@ +"""Per-user settings, re-read for new evaluations without restarting active chats.""" +import base64 +import copy +import ctypes +import json +import os +import sys +import threading +import time +from pathlib import Path + +DEFAULTS = json.loads(Path(__file__).with_name('jev-settings-defaults.json').read_text(encoding='utf-8')) +_condition = threading.Condition() +_active = 0 + + +def home(): + if '--home' in sys.argv: + return Path(sys.argv[sys.argv.index('--home') + 1]) + return Path(os.environ.get('AITURK_CODEX_BRIDGE_HOME') or Path(__file__).parent) + + +def read(): + try: + data = json.loads((home() / 'jev-settings.json').read_text(encoding='utf-8')) + except FileNotFoundError: + return copy.deepcopy(DEFAULTS), None + settings = data['settings'] + # A corrupted file must not silently change routing or contact another host. + from urllib.parse import urlsplit + url = urlsplit(settings['endpoint']) + if url.scheme != 'https' or not url.netloc or url.username or url.password: + raise ValueError('Invalid Jev endpoint') + for name in ('ret', 'sart'): + n = settings['thresholds'][name] + if not isinstance(n, (int, float)) or not 0 <= n <= 1 or not settings['questions'][name]: + raise ValueError('Invalid Jev rule') + for name in ('timeoutSeconds', 'queueWaitSeconds'): + if not isinstance(settings[name], int) or not 1 <= settings[name] <= 3600: + raise ValueError('Invalid Jev timeout') + if not isinstance(settings['maxConcurrent'], int) or not 0 <= settings['maxConcurrent'] <= 512: + raise ValueError('Invalid Jev concurrency') + if not settings['fallbackModel'] or not isinstance(settings['rejectionRules'], list) or settings['failureAction'] not in ('pass', 'next'): + raise ValueError('Invalid Jev routing') + return settings, data.get('protectedKey') + + +def key(): + _, encrypted = read() + if not encrypted: + return '' + if os.name != 'nt': + raise RuntimeError('Windows credential protection required') + from ctypes import wintypes + class Blob(ctypes.Structure): + _fields_ = [('cbData', wintypes.DWORD), ('pbData', ctypes.POINTER(ctypes.c_ubyte))] + raw = base64.b64decode(encrypted, validate=True) + buf = (ctypes.c_ubyte * len(raw)).from_buffer_copy(raw) + source, result = Blob(len(raw), buf), Blob() + decrypt = ctypes.windll.crypt32.CryptUnprotectData + decrypt.argtypes = [ctypes.POINTER(Blob), ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(Blob)] + decrypt.restype = wintypes.BOOL + if not decrypt(ctypes.byref(source), None, None, None, None, 0, ctypes.byref(result)): + raise ctypes.WinError() + try: + return ctypes.string_at(result.pbData, result.cbData).decode('utf-8') + finally: + ctypes.windll.kernel32.LocalFree.argtypes = [ctypes.c_void_p] + ctypes.windll.kernel32.LocalFree(result.pbData) + + +def acquire(settings): + global _active + deadline = time.monotonic() + settings['queueWaitSeconds'] + with _condition: + while settings['maxConcurrent'] and _active >= settings['maxConcurrent']: + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + _condition.wait(remaining) + _active += 1 + return True + + +def release(): + global _active + with _condition: + _active -= 1 + _condition.notify_all() diff --git a/apps/desktop/assets/codex-bridge/kopru.py b/apps/desktop/assets/codex-bridge/kopru.py index ac19943..30c7f37 100644 --- a/apps/desktop/assets/codex-bridge/kopru.py +++ b/apps/desktop/assets/codex-bridge/kopru.py @@ -38,7 +38,7 @@ from bekci_watch import MetadataFilter # Installed only by owned native launcher, never an HTTP control endpoint. WATCH_OWNER = None -SURUM = "11" +SURUM = "12" HERE = os.path.dirname(os.path.abspath(__file__)) try: with open(os.path.abspath(__file__), "rb") as _f: @@ -933,12 +933,24 @@ class H(http.server.BaseHTTPRequestHandler): if hata: gecmedi = True elif metin and jev_denetim.muaf_mi(hedef): - log(f"{tag} Jev atlandi: Sesli-Hizli-Zeki") + log(f"{tag} Jev atlandi: her zaman gec ({hedef})") elif metin: t0 = time.time() - puan = jev_denetim.sor(istek, metin, jev_denetim.anahtar()) + # Jev may wait for a slot or a remote response. Keep this chat alive. + import threading as jev_threading + jev_done, jev_result = jev_threading.Event(), [None] + def denetle(): + try: + jev_result[0] = jev_denetim.sor(istek, metin, jev_denetim.anahtar()) + finally: + jev_done.set() + jev_threading.Thread(target=denetle, daemon=True).start() + while not jev_done.wait(10): + w(b": jev-denetim\n\n") + puan = jev_result[0] if puan is None: - log(f"{tag} katman denetimi: Jev'e sorulamadi, cevap oldugu gibi geciyor") + gecmedi = jev_denetim.hata_gecis() + log(f"{tag} katman denetimi: Jev'e sorulamadi, {'siradaki katman' if gecmedi else 'cevap oldugu gibi geciyor'}") else: gecmedi = jev_denetim.karar(puan) log(f"{tag} katman denetimi: ret={puan['ret']:.2f} sart={puan['sart']:.2f} -> " @@ -962,11 +974,16 @@ class H(http.server.BaseHTTPRequestHandler): resp, rewriter, tag = yeni hedef = ad # Muafiyet yalnız ilk exact hedefe ait; yedek katmana taşınmaz. log(f"{tag} katman denetimi: adim bu katmana verildi") - if not kalan: + if jev_denetim.muaf_mi(hedef): return self._stream(resp, resp.status, tag, rewriter, izle, basliksiz=True) if gecmedi: # Son katman ne dondurduyse o gider, hicbir sey eklenmez; dondurmediyse hicbir sey gitmez. - w(son_govde) + if son_govde: + w(son_govde) + else: + event = {"type": "response.failed", "response": {"status": "failed", "error": { + "code": "jev_no_fallback", "message": "Jev denetiminden gecen cevap alinamadi; gecis kurallarini kontrol edin."}}} + w(b"event: response.failed\ndata: " + json.dumps(event).encode() + b"\n\n") else: for chunk in parcalar: if izle is not None: @@ -1064,7 +1081,9 @@ class H(http.server.BaseHTTPRequestHandler): "ide_model": int((sayi.split() or ["0"])[0]), "ozet": bool((CFG.get("ozet_kopru") or {}).get("enabled", False)), "is_hatti": bool(HAT_KOMUT[0]), - "adim_denetimi": bool(jev_denetim.anahtar())}) + "adim_denetimi": jev_denetim.etkin_mi() and bool(jev_denetim.anahtar()), + "jev_models": [{"id": m.get("target"), "name": m.get("display_name") or m.get("target")} + for m in EXTRA_MODELS if m.get("route") == IDE_ROUTE and m.get("target")]}) if self.command == "POST" and path.rstrip("/").endswith("/stop"): self._send_json(200, {"ok": True}) log("AITURK IDE istegiyle kapatiliyor") @@ -1720,9 +1739,9 @@ class H(http.server.BaseHTTPRequestHandler): # secili model baska bir rotada olsa bile. Gecit yoksa denetim yapilmaz. Ozet isteginde ve aracsiz # (rewriter yok) yolda kapalidir. jev_rota = ROUTES.get(ide_route) - jev_sira = ([] if ozet_tetik or rewriter is None or jev_rota is None or not jev_denetim.anahtar() - else jev_denetim.sonrakiler(target)) - jev_istek = jev_denetim.son_istek(req) if jev_sira else "" + jev_etkin = jev_denetim.etkin_mi() and not (ozet_tetik or rewriter is None or jev_rota is None or not jev_denetim.anahtar()) + jev_sira = jev_denetim.sonrakiler(target) if jev_etkin else [] + jev_istek = jev_denetim.son_istek(req) if jev_etkin else "" def jev_ac(ad): katman_req = json.loads(data) @@ -1758,7 +1777,7 @@ class H(http.server.BaseHTTPRequestHandler): log(f"{tag} deneme {attempt}: HTTP {e.code} {errbody[:100]!r} -> tekrar") time.sleep(min(2 * attempt, 8)); continue log(f"{tag} HTTP {e.code} {errbody[:200]!r}") - if jev_sira: # secili model hata verdi: adimi siradaki katman yapar + if jev_etkin: # secili model hata verdi: adimi siradaki katman yapar return self._denetimli_akis(None, tag, rewriter, izle, jev_istek, jev_sira, jev_ac, hedef=target) if pencere.tasma_mi(etxt): # Hedef "baglam sigmiyor" dedi: Codex'in tanidigi tasma hatasi olarak iletilir ve hedefin @@ -1805,10 +1824,10 @@ class H(http.server.BaseHTTPRequestHandler): sonuc = self._stream(resp, resp.status, tag, akis) log(f"{tag} ozet kopru: {akis.sonuc_notu()}") return sonuc - if jev_sira and resp.status == 200: + if jev_etkin and resp.status == 200: return self._denetimli_akis(resp, tag, rewriter, izle, jev_istek, jev_sira, jev_ac, hedef=target) return self._stream(resp, resp.status, tag, rewriter, izle) - if jev_sira: # secili modele ulasilamadi: adimi siradaki katman yapar + if jev_etkin: # secili modele ulasilamadi: adimi siradaki katman yapar return self._denetimli_akis(None, tag, rewriter, izle, jev_istek, jev_sira, jev_ac, hedef=target) self._send_json(503, {"error": {"message": f"bridge: hedef ulasilamadi {last}"}}) diff --git a/apps/desktop/assets/codex-bridge/test_jev_denetim.py b/apps/desktop/assets/codex-bridge/test_jev_denetim.py index 85abe5c..659c592 100644 --- a/apps/desktop/assets/codex-bridge/test_jev_denetim.py +++ b/apps/desktop/assets/codex-bridge/test_jev_denetim.py @@ -71,7 +71,7 @@ class Katman(unittest.TestCase): self.assertEqual(jev_denetim.sonrakiler("Katman-3"), ["Katman-4"]) def test_last_layer_is_never_asked(self): - self.assertEqual(jev_denetim.sonrakiler("Katman-4"), []) + self.assertTrue(jev_denetim.muaf_mi("Katman-4")) self.assertEqual(jev_denetim.sonrakiler(None), []) @@ -129,6 +129,7 @@ class Akis(unittest.TestCase): sahte.komutlar = jev_denetim.komutlar sahte.hazirlik_mi = jev_denetim.hazirlik_mi sahte.muaf_mi = jev_denetim.muaf_mi + sahte.hata_gecis = jev_denetim.hata_gecis env = {"jev_denetim": sahte, "yarim": yarim, "time": time, "http": __import__("http.client"), "log": self.logs.append, "RESP_HOP": set(), "json": json} adlar = ("_akis_basligi", "_denetimli_akis", "_stream") @@ -332,7 +333,7 @@ class Akis(unittest.TestCase): self.calistir(sse("Bunu yapamam.")) self.assertEqual(self.opened, ["Katman-2", "Katman-3", "Katman-4"]) self.assertNotIn(b"yapamam", self.h.wfile.getvalue()) - self.assertEqual(self.sent(), b"") # the last layer said nothing: nothing is added + self.assertIn(b'response.failed', self.sent()) def test_the_last_layers_own_error_body_is_delivered_untouched(self): class Hata(Exception): diff --git a/apps/desktop/assets/codex-bridge/test_jev_settings.py b/apps/desktop/assets/codex-bridge/test_jev_settings.py new file mode 100644 index 0000000..a876379 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/test_jev_settings.py @@ -0,0 +1,111 @@ +import copy +import json +import os +import subprocess +import tempfile +import threading +import time +import unittest +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from unittest.mock import patch + +import jev_denetim +import jev_settings + + +class Settings(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.home = Path(self.tmp.name) + self.home_patch = patch.object(jev_settings, 'home', return_value=self.home) + self.home_patch.start() + self.addCleanup(self.home_patch.stop) + self.settings = copy.deepcopy(jev_settings.DEFAULTS) + + def save(self, **changes): + self.settings.update(changes) + (self.home / 'jev-settings.json').write_text(json.dumps({'settings': self.settings}), encoding='utf-8') + + def test_live_rules_general_override_and_always_pass(self): + self.save(fallbackModel='B', rejectionRules=[{'from': 'A', 'to': 'C'}, {'from': 'C', 'to': 'D'}], exemptModels=['D']) + self.assertEqual(jev_denetim.sonrakiler('A'), ['C', 'D']) + self.assertEqual(jev_denetim.sonrakiler('Other'), ['B']) + self.assertTrue(jev_denetim.muaf_mi('D')) + self.assertFalse(jev_denetim.muaf_mi('A')) + self.save(enabled=False) + self.assertFalse(jev_denetim.etkin_mi()) + self.assertEqual(jev_denetim.sonrakiler('A'), []) + + def test_cycle_cannot_spin_even_if_file_manually_edited(self): + self.save(rejectionRules=[{'from': 'A', 'to': 'B'}, {'from': 'B', 'to': 'A'}]) + self.assertEqual(jev_denetim.sonrakiler('A'), ['B']) + + def test_prompts_model_criteria_and_thresholds_are_applied(self): + self.save(model='jev-custom', questions={'ret': 'question A', 'sart': 'question B'}, thresholds={'ret': .9, 'sart': .8}, criteria={'ret': {'true': 'bad', 'false': 'good'}, 'sart': {'true': '', 'false': ''}}) + captured = [] + def send(body, key): + captured.append(body) + return {'answers': {'ret': {'noul': .6}, 'sart': {'noul': .2}}} + result = jev_denetim.sor('request', 'answer', 'test', gonder=send) + self.assertFalse(jev_denetim.karar(result)) + self.assertEqual(captured[0]['model'], 'jev-custom') + self.assertEqual(captured[0]['questions']['ret']['instructions'], 'question A') + self.assertEqual(captured[0]['questions']['ret']['criteria'], {'true': 'bad', 'false': 'good'}) + self.assertNotIn('criteria', captured[0]['questions']['sart']) + self.save(thresholds={'ret': .1, 'sart': .1}) + self.assertFalse(jev_denetim.karar(result)) # in-flight result retains its own thresholds + + def test_concurrency_is_limited_without_serializing_all_requests(self): + self.save(maxConcurrent=2) + count, peak = 0, 0 + lock = threading.Lock() + def send(body, key): + nonlocal count, peak + with lock: + count += 1 + peak = max(peak, count) + time.sleep(.04) + with lock: + count -= 1 + return {'answers': {'ret': {'noul': 0}, 'sart': {'noul': 0}}} + with ThreadPoolExecutor(max_workers=8) as pool: + answers = list(pool.map(lambda _: jev_denetim.sor('r', 'a', 'test', gonder=send), range(8))) + self.assertEqual(peak, 2) + self.assertTrue(all(a is not None for a in answers)) + + def test_timeout_endpoint_and_failure_behavior(self): + self.save(endpoint='https://example.test/jev', timeoutSeconds=42, failureAction='next') + with patch('urllib.request.urlopen', side_effect=TimeoutError) as opening: + self.assertIsNone(jev_denetim.sor('r', 'a', 'test')) + self.assertEqual(opening.call_args.kwargs['timeout'], 42) + self.assertEqual(opening.call_args.args[0].full_url, 'https://example.test/jev') + self.assertTrue(jev_denetim.hata_gecis()) + self.assertEqual(jev_settings._active, 0) + + def test_queue_wait_expires_and_slot_count_is_preserved(self): + self.save(maxConcurrent=1, queueWaitSeconds=1) + settings = jev_settings.read()[0] + self.assertTrue(jev_settings.acquire(settings)) + try: + with patch.object(jev_denetim, '_gonder') as send: + self.assertIsNone(jev_denetim.sor('r', 'a', 'test', gonder=send)) + send.assert_not_called() + self.assertEqual(jev_settings._active, 1) + finally: + jev_settings.release() + + @unittest.skipUnless(os.name == 'nt', 'Windows DPAPI') + def test_encrypted_key_roundtrip_without_plaintext_on_disk(self): + secret = 'dummy-key-for-local-test' + code = 'Add-Type -AssemblyName System.Security; $b=[Text.Encoding]::UTF8.GetBytes([Console]::In.ReadToEnd()); [Console]::Out.Write([Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect($b,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser)))' + encrypted = subprocess.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', code], input=secret, capture_output=True, text=True, check=True, creationflags=subprocess.CREATE_NO_WINDOW).stdout.strip() + text = json.dumps({'settings': self.settings, 'protectedKey': encrypted}) + (self.home / 'jev-settings.json').write_text(text, encoding='utf-8') + self.assertNotIn(secret, text) + self.assertEqual(jev_settings.key(), secret) + + +if __name__ == '__main__': + unittest.main() diff --git a/apps/desktop/electron/codex-link-config.ts b/apps/desktop/electron/codex-link-config.ts index 8e46af1..fbdf28b 100644 --- a/apps/desktop/electron/codex-link-config.ts +++ b/apps/desktop/electron/codex-link-config.ts @@ -27,7 +27,7 @@ export const CODEX_LINK_REFRESH_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama export const CODEX_LINK_MARKER = '# AITURK IDE Codex Baglama' // Must equal SURUM in assets/codex-bridge/bridge.py; a running bridge that reports // another version is restarted so an updated IDE never keeps an old bridge alive. -export const CODEX_BRIDGE_VERSION = '11' +export const CODEX_BRIDGE_VERSION = '12' export const CODEX_LINK_LINE = `openai_base_url = "${CODEX_LINK_ORIGIN}/backend-api/codex" ${CODEX_LINK_MARKER}` export const CODEX_LINK_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index d4ce82c..ced10ee 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -378,6 +378,7 @@ import { ensureLoginShellPath } from './shell-path' import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator' import { collectSshConfigHosts, parseSshGOutput } from './ssh-config' import { createSshProbeConnection, pickLocalPort, redactSecrets, SshConnection } from './ssh-connection' +import { createStagedTransitionStore } from './staged-transition' import { createStreamThrottle } from './stream-throttle' import { registerTerminalIpc } from './terminal-ipc' import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' @@ -17192,6 +17193,21 @@ ipcMain.handle('hermes:quick-entry:settings:set', async (_event, patch) => { // resulting state and asks for changes over IPC. Windows only (the login item is // a Run-key value). All decisions live in electron/codex-link.ts. const AITURK_IDE_DATA_ROOT = path.dirname(HERMES_HOME) +const stagedTransition = createStagedTransitionStore( + path.join(AITURK_IDE_DATA_ROOT, 'codex-bridge'), + path.join(app.getPath('home'), '.typesafe', 'typesafe.env'), + value => + execFileSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + 'Add-Type -AssemblyName System.Security; $bytes=[Text.Encoding]::UTF8.GetBytes([Console]::In.ReadToEnd()); [Console]::Out.Write([Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect($bytes,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser)))' + ], + { input: value, encoding: 'utf8', windowsHide: true, timeout: 15000 } + ).trim() +) const codexLink = createCodexLink( createNodeCodexLinkDeps( @@ -17243,6 +17259,34 @@ ipcMain.handle('aiturk:codex-link:get', async event => { return codexLink.getStatus() }) +async function stagedTransitionState() { + const state = stagedTransition.get() + try { + const response = await fetch('http://127.0.0.1:8795/aiturk/codex-baglama/status', { + signal: AbortSignal.timeout(5000) + }) + if (!response.ok) throw new Error('Bridge unavailable') + const body = await response.json() + const models = Array.isArray(body.jev_models) + ? body.jev_models.filter(m => typeof m?.id === 'string' && typeof m?.name === 'string') + : [] + return { ...state, models, modelsAvailable: Array.isArray(body.jev_models) } + } catch { + return { ...state, models: [], modelsAvailable: false } + } +} + +ipcMain.handle('aiturk:staged-transition:get', async event => { + assertCodexLinkSender(event) + return stagedTransitionState() +}) + +ipcMain.handle('aiturk:staged-transition:set', async (event, patch) => { + assertCodexLinkSender(event) + stagedTransition.save(patch) + return stagedTransitionState() +}) + ipcMain.handle('aiturk:codex-link:set', async (event, patch) => { assertCodexLinkSender(event) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index f54b8f9..297ad95 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -295,6 +295,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', { ...(process.platform === 'win32' ? { codexLink: { + getStagedTransition: () => ipcRenderer.invoke('aiturk:staged-transition:get'), + setStagedTransition: patch => ipcRenderer.invoke('aiturk:staged-transition:set', patch), get: () => ipcRenderer.invoke('aiturk:codex-link:get'), set: patch => ipcRenderer.invoke('aiturk:codex-link:set', patch) } diff --git a/apps/desktop/electron/staged-transition-types.ts b/apps/desktop/electron/staged-transition-types.ts new file mode 100644 index 0000000..390b37f --- /dev/null +++ b/apps/desktop/electron/staged-transition-types.ts @@ -0,0 +1,27 @@ +export interface StagedTransitionSettings { + enabled: boolean + endpoint: string + model: string + fallbackModel: string + rejectionRules: { from: string; to: string }[] + exemptModels: string[] + thresholds: { ret: number; sart: number } + questions: { ret: string; sart: string } + criteria: { ret: { true: string; false: string }; sart: { true: string; false: string } } + timeoutSeconds: number + maxConcurrent: number + queueWaitSeconds: number + failureAction: 'pass' | 'next' +} + +export interface StagedTransitionState { + settings: StagedTransitionSettings + keyConfigured: boolean + models?: { id: string; name: string }[] + modelsAvailable?: boolean +} + +export interface StagedTransitionPatch { + settings: StagedTransitionSettings + apiKey?: string +} diff --git a/apps/desktop/electron/staged-transition.test.ts b/apps/desktop/electron/staged-transition.test.ts new file mode 100644 index 0000000..5c680ca --- /dev/null +++ b/apps/desktop/electron/staged-transition.test.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { createStagedTransitionStore, stagedTransitionDefaults, validateStagedTransition } from './staged-transition' + +const dirs: string[] = [] +afterEach(() => { + for (const dir of dirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) +describe('staged transition settings', () => { + it('persists rules and protects keys without returning a secret', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jev-settings-test-')) + dirs.push(dir) + const store = createStagedTransitionStore(dir, path.join(dir, 'missing.env'), () => 'encrypted-test-value') + const settings = structuredClone(stagedTransitionDefaults) + settings.fallbackModel = 'fallback' + settings.rejectionRules = [{ from: 'model-a', to: 'model-b' }] + const saved = store.save({ settings, apiKey: 'dummy-secret' }) + expect(saved.settings.rejectionRules).toEqual(settings.rejectionRules) + expect(saved.keyConfigured).toBe(true) + expect(JSON.stringify(saved)).not.toContain('dummy-secret') + expect(fs.readFileSync(path.join(dir, 'jev-settings.json'), 'utf8')).not.toContain('dummy-secret') + expect(store.get()).toEqual(saved) + store.save({ settings: { ...settings, timeoutSeconds: 123 } }) + expect(store.get().keyConfigured).toBe(true) + }) + it('rejects invalid rules without replacing the saved settings', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jev-settings-test-')) + dirs.push(dir) + const store = createStagedTransitionStore(dir, path.join(dir, 'missing.env'), x => x) + const before = store.save({ settings: structuredClone(stagedTransitionDefaults) }) + expect(() => + store.save({ + settings: { + ...before.settings, + rejectionRules: [ + { from: 'a', to: 'b' }, + { from: 'b', to: 'a' } + ] + } + }) + ).toThrow() + expect(store.get().settings).toEqual(before.settings) + expect(() => validateStagedTransition({ ...before.settings, maxConcurrent: -1 })).toThrow() + expect(() => validateStagedTransition({ ...before.settings, thresholds: { ret: NaN, sart: 0.7 } })).toThrow() + expect(() => validateStagedTransition({ ...before.settings, endpoint: 'http://example.com' })).toThrow() + }) +}) diff --git a/apps/desktop/electron/staged-transition.ts b/apps/desktop/electron/staged-transition.ts new file mode 100644 index 0000000..69e9127 --- /dev/null +++ b/apps/desktop/electron/staged-transition.ts @@ -0,0 +1,174 @@ +import fs from 'node:fs' +import path from 'node:path' + +import defaults from '../assets/codex-bridge/jev-settings-defaults.json' + +import type { StagedTransitionPatch, StagedTransitionSettings, StagedTransitionState } from './staged-transition-types' + +export const stagedTransitionDefaults = defaults as StagedTransitionSettings + +export function validateStagedTransition(value: unknown): StagedTransitionSettings { + if (!value || typeof value !== 'object') { + throw new Error('Invalid settings') + } + const v = value as StagedTransitionSettings + const text = (s: unknown, max = 10000): s is string => typeof s === 'string' && s.trim().length > 0 && s.length <= max + + const list = (s: unknown, allowEmpty: boolean) => + Array.isArray(s) && + (allowEmpty || s.length > 0) && + s.length <= 64 && + s.every(x => text(x, 256)) && + new Set(s.map(x => x.trim())).size === s.length + + const seconds = (n: unknown) => typeof n === 'number' && Number.isInteger(n) && n >= 1 && n <= 3600 + + if (typeof v.enabled !== 'boolean' || !text(v.endpoint, 2048) || !text(v.model, 256)) { + throw new Error('Invalid connection settings') + } + + const url = new URL(v.endpoint) + + if (url.protocol !== 'https:' || url.username || url.password || url.hash) { + throw new Error('HTTPS endpoint required') + } + + if ( + !text(v.fallbackModel, 256) || + !list(v.exemptModels, true) || + !Array.isArray(v.rejectionRules) || + v.rejectionRules.length > 64 || + v.rejectionRules.some(r => !text(r.from, 256) || !text(r.to, 256) || r.from.trim() === r.to.trim()) || + new Set(v.rejectionRules.map(r => r.from.trim())).size !== v.rejectionRules.length + ) { + throw new Error('Invalid model rules') + } + + const routes = new Map(v.rejectionRules.map(r => [r.from.trim(), r.to.trim()])) + + for (const start of routes.keys()) { + const seen = new Set() + let current: string | undefined = start + + while (current && !v.exemptModels.includes(current)) { + if (seen.has(current)) { + throw new Error('Model rules contain a cycle') + } + seen.add(current) + current = routes.get(current) ?? (current === v.fallbackModel.trim() ? undefined : v.fallbackModel.trim()) + } + } + + for (const id of ['ret', 'sart'] as const) { + if ( + !text(v.questions?.[id]) || + typeof v.thresholds?.[id] !== 'number' || + !Number.isFinite(v.thresholds[id]) || + v.thresholds[id] < 0 || + v.thresholds[id] > 1 + ) { + throw new Error('Invalid evaluation rule') + } + + for (const outcome of ['true', 'false'] as const) { + if (typeof v.criteria?.[id]?.[outcome] !== 'string' || v.criteria[id][outcome].length > 10000) { + throw new Error('Invalid decision criteria') + } + } + } + + if ( + !seconds(v.timeoutSeconds) || + !seconds(v.queueWaitSeconds) || + !Number.isInteger(v.maxConcurrent) || + v.maxConcurrent < 0 || + v.maxConcurrent > 512 || + !['pass', 'next'].includes(v.failureAction) + ) { + throw new Error('Invalid request limits') + } + + return { + enabled: v.enabled, + endpoint: v.endpoint.trim(), + model: v.model.trim(), + fallbackModel: v.fallbackModel.trim(), + rejectionRules: v.rejectionRules.map(r => ({ from: r.from.trim(), to: r.to.trim() })), + exemptModels: v.exemptModels.map(x => x.trim()), + questions: { ...v.questions }, + thresholds: { ...v.thresholds }, + criteria: structuredClone(v.criteria), + timeoutSeconds: v.timeoutSeconds, + maxConcurrent: v.maxConcurrent, + queueWaitSeconds: v.queueWaitSeconds, + failureAction: v.failureAction + } +} + +export function createStagedTransitionStore( + directory: string, + legacyKeyFile: string, + protect: (value: string) => string +) { + const file = path.join(directory, 'jev-settings.json') + + const read = (): { settings: StagedTransitionSettings; protectedKey?: string } => { + if (!fs.existsSync(file)) { + return { settings: structuredClone(stagedTransitionDefaults) } + } + const data = JSON.parse(fs.readFileSync(file, 'utf8')) + + return { settings: validateStagedTransition(data.settings), protectedKey: data.protectedKey } + } + + const state = (): StagedTransitionState => { + const current = read() + let legacy = Boolean(process.env.TYPESAFE_API_KEY?.trim()) + + if (!legacy && fs.existsSync(legacyKeyFile)) { + const match = /^\s*TYPESAFE_API_KEY\s*=\s*(.*)$/m.exec(fs.readFileSync(legacyKeyFile, 'utf8')) + legacy = Boolean( + match?.[1] + ?.trim() + .replace(/^(['"])(.*)\1$/, '$2') + .trim() + ) + } + + return { settings: current.settings, keyConfigured: Boolean(current.protectedKey) || legacy } + } + + return { + get: state, + save(patch: StagedTransitionPatch): StagedTransitionState { + const settings = validateStagedTransition(patch?.settings) + + if (patch.apiKey !== undefined && (typeof patch.apiKey !== 'string' || patch.apiKey.length > 8192)) { + throw new Error('Invalid API key') + } + + const current = read() + + const protectedKey = + patch.apiKey === undefined + ? current.protectedKey + : patch.apiKey.trim() + ? protect(patch.apiKey.trim()) + : undefined + + fs.mkdirSync(directory, { recursive: true }) + const tmp = `${file}.${process.pid}.tmp` + + try { + fs.writeFileSync(tmp, JSON.stringify({ settings, protectedKey }, null, 2), { mode: 0o600 }) + fs.renameSync(tmp, file) + } finally { + if (fs.existsSync(tmp)) { + fs.unlinkSync(tmp) + } + } + + return state() + } + } +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8deda2d..e4defba 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -304,6 +304,7 @@ "from": "assets/codex-bridge", "to": "codex-bridge", "filter": [ + "jev-settings-defaults.json", "**/*.py", "**/*.pyd", "native-watch.exe", diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index a32b771..79bc757 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -58,6 +58,7 @@ import { NotificationsSettings } from './notifications-settings' import { PluginsSettings } from './plugins-settings' import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings' import { SessionsSettings } from './sessions-settings' +import { StagedTransitionSettingsView } from './staged-transition-settings' import type { SettingsPageProps, SettingsView as SettingsViewId } from './types' const SETTINGS_VIEWS: readonly SettingsViewId[] = [ @@ -72,13 +73,14 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ 'notifications', 'billing', 'codex-link', + 'staged-transition', 'plugins', 'sessions', 'about' ] export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) { - const { t } = useI18n() + const { t, locale } = useI18n() const aiturkCopy = useAiturkCopy() const codexLinkCopy = useCodexLinkCopy() const navigate = useNavigate() @@ -267,7 +269,9 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set id: 'aiturk-phone', label: aiturkCopy.mobile.nav, onSelect: () => { - void window.hermesDesktop?.aiturkMobile?.open().catch(error => notifyError(error, aiturkCopy.mobile.openFailed)) + void window.hermesDesktop?.aiturkMobile + ?.open() + .catch(error => notifyError(error, aiturkCopy.mobile.openFailed)) } } ] @@ -282,6 +286,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set id: 'codex-link', label: codexLinkCopy.nav, onSelect: () => setActiveView('codex-link') + }, + { + active: activeView === 'staged-transition', + icon: Link, + id: 'staged-transition', + label: locale === 'tr' ? 'Kademeli Geçiş' : 'Staged Transition', + onSelect: () => setActiveView('staged-transition') } ] : []), @@ -338,7 +349,18 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set onSelect: () => setActiveView('about') } ], - [activeView, keysView, providerView, t, aiturkCopy, codexLinkCopy, setActiveView, openProviderView, openKeysView] + [ + activeView, + keysView, + providerView, + t, + locale, + aiturkCopy, + codexLinkCopy, + setActiveView, + openProviderView, + openKeysView + ] ) // Type-to-search: printable keystrokes on the Settings surface (outside any @@ -459,6 +481,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set ) : activeView === 'codex-link' ? ( + ) : activeView === 'staged-transition' ? ( + ) : ( ) diff --git a/apps/desktop/src/app/settings/staged-transition-settings.test.tsx b/apps/desktop/src/app/settings/staged-transition-settings.test.tsx new file mode 100644 index 0000000..bb7b710 --- /dev/null +++ b/apps/desktop/src/app/settings/staged-transition-settings.test.tsx @@ -0,0 +1,71 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n/context' + +import defaults from '../../../assets/codex-bridge/jev-settings-defaults.json' + +import { StagedTransitionSettingsView } from './staged-transition-settings' + +const get = vi.fn() +const set = vi.fn() + +const state = () => ({ + settings: structuredClone(defaults), + keyConfigured: true, + modelsAvailable: true, + models: [ + { id: 'A', name: 'Model A' }, + { id: 'B', name: 'Model B' } + ] +}) + +beforeEach(() => { + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { + codexLink: { getStagedTransition: get, setStagedTransition: set } + } + get.mockResolvedValue(state()) + set.mockImplementation(async patch => ({ ...state(), settings: patch.settings })) +}) +afterEach(() => { + cleanup() + vi.resetAllMocks() +}) + +const show = () => + render( + + + + ) + +describe('staged transition settings UI', () => { + it('adds and removes model routing and always-pass rows', async () => { + show() + await screen.findByText('Kademeli Geçiş') + fireEvent.click(screen.getByText('+ Geçiş kuralı ekle')) + expect(screen.getByLabelText('İtiraz edilen model 4')).toBeTruthy() + expect(screen.getByLabelText('Kullanılacak model 4')).toBeTruthy() + fireEvent.click(screen.getByLabelText('Geçiş kuralı 4 sil')) + expect(screen.queryByLabelText('İtiraz edilen model 4')).toBeNull() + fireEvent.click(screen.getByText('+ Her zaman geç kuralı ekle')) + expect(screen.getByLabelText('Her zaman geçen model 3')).toBeTruthy() + fireEvent.click(screen.getByLabelText('Her zaman geç kuralı 3 sil')) + fireEvent.change(screen.getByLabelText('İsteği yerine getirmeme denetim metni'), { + target: { value: 'custom prompt' } + }) + fireEvent.click(screen.getByText('Kaydet')) + await screen.findByText('Kaydedildi. Yeni değerlendirmelerde uygulanır.') + expect(set.mock.calls[0][0].settings.questions.ret).toBe('custom prompt') + expect(set.mock.calls[0][0]).not.toHaveProperty('apiKey') + }) + it('retains user edits if persistence fails', async () => { + set.mockRejectedValue(new Error('disk error')) + show() + await screen.findByText('Kademeli Geçiş') + fireEvent.change(screen.getByLabelText('Jev modeli'), { target: { value: 'new-model' } }) + fireEvent.click(screen.getByText('Kaydet')) + await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('Kaydedilemedi')) + expect((screen.getByLabelText('Jev modeli') as HTMLInputElement).value).toBe('new-model') + }) +}) diff --git a/apps/desktop/src/app/settings/staged-transition-settings.tsx b/apps/desktop/src/app/settings/staged-transition-settings.tsx new file mode 100644 index 0000000..a846081 --- /dev/null +++ b/apps/desktop/src/app/settings/staged-transition-settings.tsx @@ -0,0 +1,463 @@ +import { useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { useI18n } from '@/i18n/context' +import { Link } from '@/lib/icons' + +import defaultSettings from '../../../assets/codex-bridge/jev-settings-defaults.json' +import type { StagedTransitionSettings, StagedTransitionState } from '../../../electron/staged-transition-types' + +import { ListRow, SettingsContent, SettingsSection } from './primitives' + +export function StagedTransitionSettingsView() { + const { locale } = useI18n() + const tr = locale === 'tr' + const text = (turkish: string, english: string) => (tr ? turkish : english) + const api = window.hermesDesktop?.codexLink + const [state, setState] = useState(null) + const [draft, setDraft] = useState(null) + const [key, setKey] = useState('') + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState('') + const [error, setError] = useState(false) + + const adopt = (next: StagedTransitionState) => { + setState(next) + setDraft(next.settings) + } + + useEffect(() => { + let active = true + + if (api?.getStagedTransition) { + api.getStagedTransition().then( + next => { + if (active) { + adopt(next) + } + }, + () => { + if (active) { + setError(true) + setMessage(tr ? 'Ayarlar okunamadı.' : 'Could not read settings.') + } + } + ) + } + + return () => { + active = false + } + }, [api, tr]) + + if (!api?.getStagedTransition) { + return ( + +

+ {text('Bu bölüm Windows masaüstü uygulamasında kullanılabilir.', 'Available in the Windows desktop app.')} +

+
+ ) + } + + if (!draft) { + return ( + +

{message || text('Yükleniyor…', 'Loading…')}

+
+ ) + } + + const change = (name: K, value: StagedTransitionSettings[K]) => + setDraft({ ...draft, [name]: value }) + + const models = new Map((state?.models ?? []).map(model => [model.id, model.name])) + + const storedModels = [ + draft.fallbackModel, + ...draft.exemptModels, + ...draft.rejectionRules.flatMap(rule => [rule.from, rule.to]) + ] + + for (const id of storedModels) { + if (id && !models.has(id)) { + models.set(id, `${id} (${text('kayıtlı', 'saved')})`) + } + } + + const modelSelect = (label: string, value: string, onChange: (value: string) => void) => ( +
+ +
+ ) + + const number = ( + name: 'timeoutSeconds' | 'maxConcurrent' | 'queueWaitSeconds', + label: string, + min: number, + max: number + ) => ( + change(name, Number(event.target.value))} + type="number" + value={draft[name]} + /> + } + title={label} + /> + ) + + const save = async () => { + setBusy(true) + setMessage('') + setError(false) + + try { + adopt(await api.setStagedTransition({ settings: draft, ...(key.trim() ? { apiKey: key.trim() } : {}) })) + setKey('') + setMessage(text('Kaydedildi. Yeni değerlendirmelerde uygulanır.', 'Saved. Applies to new evaluations.')) + } catch (failure) { + setError(true) + const cycle = String(failure).includes('cycle') + setMessage( + cycle + ? text( + 'Geçiş kuralları döngü oluşturuyor. Aynı modele geri dönen geçişi değiştirin.', + 'Transition rules form a cycle. Change the rule that returns to an earlier model.' + ) + : text( + 'Kaydedilemedi. Alanları ve sayı aralıklarını kontrol edin; önceki ayarlar korunuyor.', + 'Could not save. Check the fields and number ranges; previous settings are preserved.' + ) + ) + } finally { + setBusy(false) + } + } + + return ( + +
+ + change('enabled', value)} + /> + } + description={text( + 'Köprüdeki ek modellerin nihai cevaplarını denetler. Uygun bulunmayan cevap sıradaki katmanda yeniden hazırlanır.', + 'Evaluates final answers from bridge models. Rejected answers are retried with the next layer.' + )} + title={text('Jev denetimi ve kademeli geçiş', 'Jev evaluation and staged transition')} + /> + change('model', e.target.value)} + value={draft.model} + /> + } + title={text('Jev modeli', 'Jev model')} + /> + change('endpoint', e.target.value)} + type="url" + value={draft.endpoint} + /> + } + title={text('API adresi', 'API endpoint')} + /> + setKey(e.target.value)} + type="password" + value={key} + /> + } + description={ + state?.keyConfigured + ? text( + 'Anahtar tanımlı. Değiştirmek için yeni anahtarı girin.', + 'Key configured. Enter a new key to replace it.' + ) + : text('Henüz anahtar tanımlı değil.', 'No key configured.') + } + title={text('TypeSafe API anahtarı', 'TypeSafe API key')} + /> + + + {(['ret', 'sart'] as const).map(id => { + const label = + id === 'ret' + ? text('İsteği yerine getirmeme', 'Request not fulfilled') + : text('İsteği kendiliğinden değiştirme', 'Unrequested changes') + + return ( +
+ change('thresholds', { ...draft.thresholds, [id]: Number(e.target.value) })} + step={0.01} + type="number" + value={draft.thresholds[id]} + /> + } + description={text( + '0–1 arasında geçiş eşiği. Olasılık bu değere ulaşırsa sıradaki katman denenir.', + 'Transition threshold from 0 to 1. A probability at or above this value tries the next layer.' + )} + title={label} + /> +