feat(settings): configure Jev evaluation and model transitions
This commit is contained in:
1 parent
369f6c2ca8
commit
883b1dc824
20 files changed
+1195
-37
No files matched your search
@@ -34,7 +34,7 @@ import urllib.request
|
|||||||
import gecis_context
|
import gecis_context
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
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:
|
try:
|
||||||
with open(os.path.abspath(__file__), "rb") as _f:
|
with open(os.path.abspath(__file__), "rb") as _f:
|
||||||
KOD = hashlib.sha256(_f.read()).hexdigest()[:8]
|
KOD = hashlib.sha256(_f.read()).hexdigest()[:8]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import jev_settings
|
||||||
|
|
||||||
ADRES = "https://api.typesafe.ai/v1/systemone"
|
ADRES = "https://api.typesafe.ai/v1/systemone"
|
||||||
MODEL = "jev-1.13.0"
|
MODEL = "jev-1.13.0"
|
||||||
@@ -32,26 +33,44 @@ SORU = {
|
|||||||
_ANAHTAR = {"okundu": False, "deger": ""}
|
_ANAHTAR = {"okundu": False, "deger": ""}
|
||||||
|
|
||||||
def muaf_mi(hedef):
|
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.
|
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):
|
def sonrakiler(hedef):
|
||||||
"""Jev gecirmezse sirayla denenecek katmanlar. Secili model bir katmansa ondan sonrakiler; katman degilse
|
"""Model-specific rejection edges take precedence over the general fallback.
|
||||||
butun katmanlar bastan (once Katman-1). Son katman (Katman-4) kendisi gelince bos doner: sorgulanmadan gecer."""
|
|
||||||
|
Stop at an always-pass model or before revisiting any target.
|
||||||
|
"""
|
||||||
|
settings = jev_settings.read()[0]
|
||||||
ad = str(hedef or "")
|
ad = str(hedef or "")
|
||||||
if not ad or ad == KATMAN[-1]:
|
if not settings['enabled'] or not ad:
|
||||||
return []
|
return []
|
||||||
if ad in KATMAN:
|
routes = {r['from']: r['to'] for r in settings['rejectionRules']}
|
||||||
return list(KATMAN[KATMAN.index(ad) + 1:])
|
result, seen = [], {ad}
|
||||||
return list(KATMAN)
|
# 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):
|
def anahtar(ortam=None, dosyalar=None):
|
||||||
"""TypeSafe anahtari: ortam degiskeni, sonra ~/.typesafe/typesafe.env. Yoksa ""."""
|
"""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
|
ortam = os.environ if ortam is None else ortam
|
||||||
deger = str(ortam.get("TYPESAFE_API_KEY") or "").strip()
|
deger = str(ortam.get("TYPESAFE_API_KEY") or "").strip()
|
||||||
if deger:
|
if deger:
|
||||||
@@ -147,24 +166,53 @@ def komutlar(govde):
|
|||||||
return "\n".join(satirlar)
|
return "\n".join(satirlar)
|
||||||
|
|
||||||
|
|
||||||
def _gonder(govde, key):
|
def _gonder(govde, key, settings=None):
|
||||||
istek = urllib.request.Request(ADRES, data=json.dumps(govde).encode("utf-8"), method="POST",
|
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"})
|
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)
|
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):
|
def sor(istek, cevap, key, gonder=_gonder):
|
||||||
"""Jev'e sorar. Donus: {"ret", "sart"} olasiliklari | None (sorulamadi)."""
|
"""Jev'e sorar. Donus: {"ret", "sart"} olasiliklari | None (sorulamadi)."""
|
||||||
govde = {"model": MODEL, "state": {"user_request": istek, "assistant_reply": cevap},
|
settings = jev_settings.read()[0]
|
||||||
"questions": {k: {"type": "noul", "instructions": v} for k, v in SORU.items()}}
|
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:
|
try:
|
||||||
yanitlar = (gonder(govde, key) or {}).get("answers") or {}
|
response = gonder(govde, key, settings) if gonder is _gonder else gonder(govde, key)
|
||||||
return {k: float(yanitlar[k]["noul"]) for k in SORU}
|
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:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
finally:
|
||||||
|
jev_settings.release()
|
||||||
|
|
||||||
|
|
||||||
def karar(puan):
|
def karar(puan):
|
||||||
"""True: adim gecmez, sonraki katman yapar. Istisna yok."""
|
"""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'])
|
||||||
@@ -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()
|
||||||
@@ -38,7 +38,7 @@ from bekci_watch import MetadataFilter
|
|||||||
# Installed only by owned native launcher, never an HTTP control endpoint.
|
# Installed only by owned native launcher, never an HTTP control endpoint.
|
||||||
WATCH_OWNER = None
|
WATCH_OWNER = None
|
||||||
|
|
||||||
SURUM = "11"
|
SURUM = "12"
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
try:
|
try:
|
||||||
with open(os.path.abspath(__file__), "rb") as _f:
|
with open(os.path.abspath(__file__), "rb") as _f:
|
||||||
@@ -933,12 +933,24 @@ class H(http.server.BaseHTTPRequestHandler):
|
|||||||
if hata:
|
if hata:
|
||||||
gecmedi = True
|
gecmedi = True
|
||||||
elif metin and jev_denetim.muaf_mi(hedef):
|
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:
|
elif metin:
|
||||||
t0 = time.time()
|
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:
|
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:
|
else:
|
||||||
gecmedi = jev_denetim.karar(puan)
|
gecmedi = jev_denetim.karar(puan)
|
||||||
log(f"{tag} katman denetimi: ret={puan['ret']:.2f} sart={puan['sart']:.2f} -> "
|
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
|
resp, rewriter, tag = yeni
|
||||||
hedef = ad # Muafiyet yalnız ilk exact hedefe ait; yedek katmana taşınmaz.
|
hedef = ad # Muafiyet yalnız ilk exact hedefe ait; yedek katmana taşınmaz.
|
||||||
log(f"{tag} katman denetimi: adim bu katmana verildi")
|
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)
|
return self._stream(resp, resp.status, tag, rewriter, izle, basliksiz=True)
|
||||||
if gecmedi:
|
if gecmedi:
|
||||||
# Son katman ne dondurduyse o gider, hicbir sey eklenmez; dondurmediyse hicbir sey gitmez.
|
# 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:
|
else:
|
||||||
for chunk in parcalar:
|
for chunk in parcalar:
|
||||||
if izle is not None:
|
if izle is not None:
|
||||||
@@ -1064,7 +1081,9 @@ class H(http.server.BaseHTTPRequestHandler):
|
|||||||
"ide_model": int((sayi.split() or ["0"])[0]),
|
"ide_model": int((sayi.split() or ["0"])[0]),
|
||||||
"ozet": bool((CFG.get("ozet_kopru") or {}).get("enabled", False)),
|
"ozet": bool((CFG.get("ozet_kopru") or {}).get("enabled", False)),
|
||||||
"is_hatti": bool(HAT_KOMUT[0]),
|
"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"):
|
if self.command == "POST" and path.rstrip("/").endswith("/stop"):
|
||||||
self._send_json(200, {"ok": True})
|
self._send_json(200, {"ok": True})
|
||||||
log("AITURK IDE istegiyle kapatiliyor")
|
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
|
# secili model baska bir rotada olsa bile. Gecit yoksa denetim yapilmaz. Ozet isteginde ve aracsiz
|
||||||
# (rewriter yok) yolda kapalidir.
|
# (rewriter yok) yolda kapalidir.
|
||||||
jev_rota = ROUTES.get(ide_route)
|
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()
|
jev_etkin = jev_denetim.etkin_mi() and not (ozet_tetik or rewriter is None or jev_rota is None or not jev_denetim.anahtar())
|
||||||
else jev_denetim.sonrakiler(target))
|
jev_sira = jev_denetim.sonrakiler(target) if jev_etkin else []
|
||||||
jev_istek = jev_denetim.son_istek(req) if jev_sira else ""
|
jev_istek = jev_denetim.son_istek(req) if jev_etkin else ""
|
||||||
|
|
||||||
def jev_ac(ad):
|
def jev_ac(ad):
|
||||||
katman_req = json.loads(data)
|
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")
|
log(f"{tag} deneme {attempt}: HTTP {e.code} {errbody[:100]!r} -> tekrar")
|
||||||
time.sleep(min(2 * attempt, 8)); continue
|
time.sleep(min(2 * attempt, 8)); continue
|
||||||
log(f"{tag} HTTP {e.code} {errbody[:200]!r}")
|
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)
|
return self._denetimli_akis(None, tag, rewriter, izle, jev_istek, jev_sira, jev_ac, hedef=target)
|
||||||
if pencere.tasma_mi(etxt):
|
if pencere.tasma_mi(etxt):
|
||||||
# Hedef "baglam sigmiyor" dedi: Codex'in tanidigi tasma hatasi olarak iletilir ve hedefin
|
# 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)
|
sonuc = self._stream(resp, resp.status, tag, akis)
|
||||||
log(f"{tag} ozet kopru: {akis.sonuc_notu()}")
|
log(f"{tag} ozet kopru: {akis.sonuc_notu()}")
|
||||||
return sonuc
|
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._denetimli_akis(resp, tag, rewriter, izle, jev_istek, jev_sira, jev_ac, hedef=target)
|
||||||
return self._stream(resp, resp.status, tag, rewriter, izle)
|
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)
|
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}"}})
|
self._send_json(503, {"error": {"message": f"bridge: hedef ulasilamadi {last}"}})
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class Katman(unittest.TestCase):
|
|||||||
self.assertEqual(jev_denetim.sonrakiler("Katman-3"), ["Katman-4"])
|
self.assertEqual(jev_denetim.sonrakiler("Katman-3"), ["Katman-4"])
|
||||||
|
|
||||||
def test_last_layer_is_never_asked(self):
|
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), [])
|
self.assertEqual(jev_denetim.sonrakiler(None), [])
|
||||||
|
|
||||||
|
|
||||||
@@ -129,6 +129,7 @@ class Akis(unittest.TestCase):
|
|||||||
sahte.komutlar = jev_denetim.komutlar
|
sahte.komutlar = jev_denetim.komutlar
|
||||||
sahte.hazirlik_mi = jev_denetim.hazirlik_mi
|
sahte.hazirlik_mi = jev_denetim.hazirlik_mi
|
||||||
sahte.muaf_mi = jev_denetim.muaf_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"),
|
env = {"jev_denetim": sahte, "yarim": yarim, "time": time, "http": __import__("http.client"),
|
||||||
"log": self.logs.append, "RESP_HOP": set(), "json": json}
|
"log": self.logs.append, "RESP_HOP": set(), "json": json}
|
||||||
adlar = ("_akis_basligi", "_denetimli_akis", "_stream")
|
adlar = ("_akis_basligi", "_denetimli_akis", "_stream")
|
||||||
@@ -332,7 +333,7 @@ class Akis(unittest.TestCase):
|
|||||||
self.calistir(sse("Bunu yapamam."))
|
self.calistir(sse("Bunu yapamam."))
|
||||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3", "Katman-4"])
|
self.assertEqual(self.opened, ["Katman-2", "Katman-3", "Katman-4"])
|
||||||
self.assertNotIn(b"yapamam", self.h.wfile.getvalue())
|
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):
|
def test_the_last_layers_own_error_body_is_delivered_untouched(self):
|
||||||
class Hata(Exception):
|
class Hata(Exception):
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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'
|
export const CODEX_LINK_MARKER = '# AITURK IDE Codex Baglama'
|
||||||
// Must equal SURUM in assets/codex-bridge/bridge.py; a running bridge that reports
|
// 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.
|
// 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_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'
|
export const CODEX_LINK_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
|
||||||
|
|||||||
@@ -378,6 +378,7 @@ import { ensureLoginShellPath } from './shell-path'
|
|||||||
import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator'
|
import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator'
|
||||||
import { collectSshConfigHosts, parseSshGOutput } from './ssh-config'
|
import { collectSshConfigHosts, parseSshGOutput } from './ssh-config'
|
||||||
import { createSshProbeConnection, pickLocalPort, redactSecrets, SshConnection } from './ssh-connection'
|
import { createSshProbeConnection, pickLocalPort, redactSecrets, SshConnection } from './ssh-connection'
|
||||||
|
import { createStagedTransitionStore } from './staged-transition'
|
||||||
import { createStreamThrottle } from './stream-throttle'
|
import { createStreamThrottle } from './stream-throttle'
|
||||||
import { registerTerminalIpc } from './terminal-ipc'
|
import { registerTerminalIpc } from './terminal-ipc'
|
||||||
import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width'
|
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
|
// 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.
|
// a Run-key value). All decisions live in electron/codex-link.ts.
|
||||||
const AITURK_IDE_DATA_ROOT = path.dirname(HERMES_HOME)
|
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(
|
const codexLink = createCodexLink(
|
||||||
createNodeCodexLinkDeps(
|
createNodeCodexLinkDeps(
|
||||||
@@ -17243,6 +17259,34 @@ ipcMain.handle('aiturk:codex-link:get', async event => {
|
|||||||
return codexLink.getStatus()
|
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) => {
|
ipcMain.handle('aiturk:codex-link:set', async (event, patch) => {
|
||||||
assertCodexLinkSender(event)
|
assertCodexLinkSender(event)
|
||||||
|
|
||||||
|
|||||||
@@ -295,6 +295,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||||||
...(process.platform === 'win32'
|
...(process.platform === 'win32'
|
||||||
? {
|
? {
|
||||||
codexLink: {
|
codexLink: {
|
||||||
|
getStagedTransition: () => ipcRenderer.invoke('aiturk:staged-transition:get'),
|
||||||
|
setStagedTransition: patch => ipcRenderer.invoke('aiturk:staged-transition:set', patch),
|
||||||
get: () => ipcRenderer.invoke('aiturk:codex-link:get'),
|
get: () => ipcRenderer.invoke('aiturk:codex-link:get'),
|
||||||
set: patch => ipcRenderer.invoke('aiturk:codex-link:set', patch)
|
set: patch => ipcRenderer.invoke('aiturk:codex-link:set', patch)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string>()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -304,6 +304,7 @@
|
|||||||
"from": "assets/codex-bridge",
|
"from": "assets/codex-bridge",
|
||||||
"to": "codex-bridge",
|
"to": "codex-bridge",
|
||||||
"filter": [
|
"filter": [
|
||||||
|
"jev-settings-defaults.json",
|
||||||
"**/*.py",
|
"**/*.py",
|
||||||
"**/*.pyd",
|
"**/*.pyd",
|
||||||
"native-watch.exe",
|
"native-watch.exe",
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import { NotificationsSettings } from './notifications-settings'
|
|||||||
import { PluginsSettings } from './plugins-settings'
|
import { PluginsSettings } from './plugins-settings'
|
||||||
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
|
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
|
||||||
import { SessionsSettings } from './sessions-settings'
|
import { SessionsSettings } from './sessions-settings'
|
||||||
|
import { StagedTransitionSettingsView } from './staged-transition-settings'
|
||||||
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
|
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
|
||||||
|
|
||||||
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||||
@@ -72,13 +73,14 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
|||||||
'notifications',
|
'notifications',
|
||||||
'billing',
|
'billing',
|
||||||
'codex-link',
|
'codex-link',
|
||||||
|
'staged-transition',
|
||||||
'plugins',
|
'plugins',
|
||||||
'sessions',
|
'sessions',
|
||||||
'about'
|
'about'
|
||||||
]
|
]
|
||||||
|
|
||||||
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||||
const { t } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const aiturkCopy = useAiturkCopy()
|
const aiturkCopy = useAiturkCopy()
|
||||||
const codexLinkCopy = useCodexLinkCopy()
|
const codexLinkCopy = useCodexLinkCopy()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -267,7 +269,9 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||||||
id: 'aiturk-phone',
|
id: 'aiturk-phone',
|
||||||
label: aiturkCopy.mobile.nav,
|
label: aiturkCopy.mobile.nav,
|
||||||
onSelect: () => {
|
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',
|
id: 'codex-link',
|
||||||
label: codexLinkCopy.nav,
|
label: codexLinkCopy.nav,
|
||||||
onSelect: () => setActiveView('codex-link')
|
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')
|
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
|
// Type-to-search: printable keystrokes on the Settings surface (outside any
|
||||||
@@ -459,6 +481,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||||||
<PluginsSettings />
|
<PluginsSettings />
|
||||||
) : activeView === 'codex-link' ? (
|
) : activeView === 'codex-link' ? (
|
||||||
<CodexLinkSettings />
|
<CodexLinkSettings />
|
||||||
|
) : activeView === 'staged-transition' ? (
|
||||||
|
<StagedTransitionSettingsView />
|
||||||
) : (
|
) : (
|
||||||
<SessionsSettings />
|
<SessionsSettings />
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<I18nProvider configClient={null} initialLocale="tr">
|
||||||
|
<StagedTransitionSettingsView />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<StagedTransitionState | null>(null)
|
||||||
|
const [draft, setDraft] = useState<StagedTransitionSettings | null>(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 (
|
||||||
|
<SettingsContent>
|
||||||
|
<p>
|
||||||
|
{text('Bu bölüm Windows masaüstü uygulamasında kullanılabilir.', 'Available in the Windows desktop app.')}
|
||||||
|
</p>
|
||||||
|
</SettingsContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!draft) {
|
||||||
|
return (
|
||||||
|
<SettingsContent>
|
||||||
|
<p role={error ? 'alert' : 'status'}>{message || text('Yükleniyor…', 'Loading…')}</p>
|
||||||
|
</SettingsContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const change = <K extends keyof StagedTransitionSettings>(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) => (
|
||||||
|
<div className="w-full min-w-0 sm:w-auto sm:min-w-48 sm:flex-1">
|
||||||
|
<Select onValueChange={onChange} value={value}>
|
||||||
|
<SelectTrigger aria-label={label}>
|
||||||
|
<SelectValue placeholder={text('Model seçin', 'Select a model')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.from(models, ([id, name]) => (
|
||||||
|
<SelectItem key={id} value={id}>
|
||||||
|
{name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const number = (
|
||||||
|
name: 'timeoutSeconds' | 'maxConcurrent' | 'queueWaitSeconds',
|
||||||
|
label: string,
|
||||||
|
min: number,
|
||||||
|
max: number
|
||||||
|
) => (
|
||||||
|
<ListRow
|
||||||
|
action={
|
||||||
|
<Input
|
||||||
|
aria-label={label}
|
||||||
|
max={max}
|
||||||
|
min={min}
|
||||||
|
onChange={event => 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 (
|
||||||
|
<SettingsContent>
|
||||||
|
<fieldset className="min-w-0" disabled={busy}>
|
||||||
|
<SettingsSection icon={Link} title={text('Kademeli Geçiş', 'Staged Transition')}>
|
||||||
|
<ListRow
|
||||||
|
action={
|
||||||
|
<Switch
|
||||||
|
aria-label={text('Jev denetimi', 'Jev evaluation')}
|
||||||
|
checked={draft.enabled}
|
||||||
|
onCheckedChange={value => 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')}
|
||||||
|
/>
|
||||||
|
<ListRow
|
||||||
|
action={
|
||||||
|
<Input
|
||||||
|
aria-label={text('Jev modeli', 'Jev model')}
|
||||||
|
onChange={e => change('model', e.target.value)}
|
||||||
|
value={draft.model}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={text('Jev modeli', 'Jev model')}
|
||||||
|
/>
|
||||||
|
<ListRow
|
||||||
|
action={
|
||||||
|
<Input
|
||||||
|
aria-label={text('API adresi', 'API endpoint')}
|
||||||
|
onChange={e => change('endpoint', e.target.value)}
|
||||||
|
type="url"
|
||||||
|
value={draft.endpoint}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={text('API adresi', 'API endpoint')}
|
||||||
|
/>
|
||||||
|
<ListRow
|
||||||
|
action={
|
||||||
|
<Input
|
||||||
|
aria-label={text('TypeSafe API anahtarı', 'TypeSafe API key')}
|
||||||
|
autoComplete="new-password"
|
||||||
|
onChange={e => 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')}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
<SettingsSection icon={Link} title={text('Denetim kuralları', 'Evaluation rules')}>
|
||||||
|
{(['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 (
|
||||||
|
<div key={id}>
|
||||||
|
<ListRow
|
||||||
|
action={
|
||||||
|
<Input
|
||||||
|
aria-label={`${label} ${text('eşiği', 'threshold')}`}
|
||||||
|
max={1}
|
||||||
|
min={0}
|
||||||
|
onChange={e => 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}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
aria-label={`${label} ${text('denetim metni', 'instructions')}`}
|
||||||
|
onChange={e => change('questions', { ...draft.questions, [id]: e.target.value })}
|
||||||
|
rows={4}
|
||||||
|
value={draft.questions[id]}
|
||||||
|
/>
|
||||||
|
{(['true', 'false'] as const).map(outcome => (
|
||||||
|
<label className="block py-2" key={outcome}>
|
||||||
|
<span>
|
||||||
|
{outcome === 'true'
|
||||||
|
? text(
|
||||||
|
'“Evet, sorun var” ne demek? (isteğe bağlı)',
|
||||||
|
'What does “yes, there is a problem” mean? (optional)'
|
||||||
|
)
|
||||||
|
: text(
|
||||||
|
'“Hayır, sorun yok” ne demek? (isteğe bağlı)',
|
||||||
|
'What does “no problem” mean? (optional)'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Textarea
|
||||||
|
aria-label={`${label} ${outcome}`}
|
||||||
|
onChange={e =>
|
||||||
|
change('criteria', {
|
||||||
|
...draft.criteria,
|
||||||
|
[id]: { ...draft.criteria[id], [outcome]: e.target.value }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
rows={2}
|
||||||
|
value={draft.criteria[id][outcome]}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SettingsSection>
|
||||||
|
<SettingsSection
|
||||||
|
icon={Link}
|
||||||
|
title={text('Olasılık, onay ve sıcaklık', 'Probability, approval and temperature')}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{text(
|
||||||
|
'Jev her denetim sorusu için 0–1 arasında sorun olasılığı verir. Her iki olasılık da kendi itiraz eşiğinin altındaysa cevap geçer. Örneğin 0,70 eşiği, sorun olasılığı %70 veya üstündeyse geçiş demektir; %70 kalite garantisi değildir.',
|
||||||
|
'Jev returns a problem probability from 0 to 1 for each question. An answer passes when both probabilities are below their rejection thresholds. A threshold of 0.70 triggers transition at 70% problem probability; it is not a quality guarantee.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<ListRow
|
||||||
|
description={text('TypeSafe API bu parametreyi sunmuyor.', 'Not exposed by the TypeSafe API.')}
|
||||||
|
title={text('Sıcaklık', 'Temperature')}
|
||||||
|
/>
|
||||||
|
<ListRow
|
||||||
|
description={text(
|
||||||
|
'Kullanılan Noul (evet/hayır) türünde ayrı güven skoru yoktur. Karar yukarıdaki olasılık eşikleriyle verilir.',
|
||||||
|
'The Noul (yes/no) type has no separate confidence score. Decisions use the probability thresholds above.'
|
||||||
|
)}
|
||||||
|
title={text('Ayrı emin olma / güven değeri', 'Separate confidence value')}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
<SettingsSection icon={Link} title={text('Geçiş kuralları', 'Transition rules')}>
|
||||||
|
{!state?.modelsAvailable && (
|
||||||
|
<p role="status">
|
||||||
|
{text(
|
||||||
|
'Canlı model listesi alınamadı. Kayıtlı seçimler korunuyor; Codex Bağlama açıkken listeyi yenileyin.',
|
||||||
|
'Live model list unavailable. Saved selections are preserved; refresh with Codex Link enabled.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const next = await api.getStagedTransition()
|
||||||
|
setState(next)
|
||||||
|
} catch {
|
||||||
|
setError(true)
|
||||||
|
setMessage(text('Model listesi alınamadı.', 'Could not load models.'))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{text('Model listesini yenile', 'Refresh models')}
|
||||||
|
</Button>
|
||||||
|
<ListRow
|
||||||
|
action={modelSelect(text('Genel geçiş modeli', 'Default fallback model'), draft.fallbackModel, value =>
|
||||||
|
change('fallbackModel', value)
|
||||||
|
)}
|
||||||
|
description={text(
|
||||||
|
'Modele özel kural yoksa seçilen model kullanılır.',
|
||||||
|
'Uses this model when there is no model-specific rule.'
|
||||||
|
)}
|
||||||
|
title={text('Jev herhangi bir modele itiraz ederse', 'When Jev rejects any model')}
|
||||||
|
/>
|
||||||
|
{draft.rejectionRules.map((rule, index) => (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 py-2" key={index}>
|
||||||
|
{modelSelect(text(`İtiraz edilen model ${index + 1}`, `Rejected model ${index + 1}`), rule.from, value =>
|
||||||
|
change(
|
||||||
|
'rejectionRules',
|
||||||
|
draft.rejectionRules.map((r, i) => (i === index ? { ...r, from: value } : r))
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<span>{text('modeline Jev itiraz ederse', 'rejected by Jev →')}</span>
|
||||||
|
{modelSelect(text(`Kullanılacak model ${index + 1}`, `Fallback model ${index + 1}`), rule.to, value =>
|
||||||
|
change(
|
||||||
|
'rejectionRules',
|
||||||
|
draft.rejectionRules.map((r, i) => (i === index ? { ...r, to: value } : r))
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<span>{text('modeli kullanılsın', 'use this model')}</span>
|
||||||
|
<Button
|
||||||
|
aria-label={text(`Geçiş kuralı ${index + 1} sil`, `Remove rule ${index + 1}`)}
|
||||||
|
onClick={() =>
|
||||||
|
change(
|
||||||
|
'rejectionRules',
|
||||||
|
draft.rejectionRules.filter((_, i) => i !== index)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{text('Sil', 'Remove')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
onClick={() => change('rejectionRules', [...draft.rejectionRules, { from: '', to: '' }])}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{text('+ Geçiş kuralı ekle', '+ Add transition rule')}
|
||||||
|
</Button>
|
||||||
|
</SettingsSection>
|
||||||
|
<SettingsSection icon={Link} title={text('Jev’den her zaman geçsin', 'Always pass Jev')}>
|
||||||
|
<p>
|
||||||
|
{text(
|
||||||
|
'Bu modeller Jev denetimine gönderilmez. Bu seçim, modele özel ve genel itiraz kurallarından önce uygulanır.',
|
||||||
|
'These models bypass Jev evaluation. This takes priority over model-specific and default rejection rules.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{draft.exemptModels.map((model, index) => (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 py-2" key={index}>
|
||||||
|
{modelSelect(text(`Her zaman geçen model ${index + 1}`, `Always-pass model ${index + 1}`), model, value =>
|
||||||
|
change(
|
||||||
|
'exemptModels',
|
||||||
|
draft.exemptModels.map((m, i) => (i === index ? value : m))
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<span>{text('modeli Jev’den her zaman geçsin', 'always passes Jev')}</span>
|
||||||
|
<Button
|
||||||
|
aria-label={text(`Her zaman geç kuralı ${index + 1} sil`, `Remove always-pass rule ${index + 1}`)}
|
||||||
|
onClick={() =>
|
||||||
|
change(
|
||||||
|
'exemptModels',
|
||||||
|
draft.exemptModels.filter((_, i) => i !== index)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{text('Sil', 'Remove')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button onClick={() => change('exemptModels', [...draft.exemptModels, ''])} variant="outline">
|
||||||
|
{text('+ Her zaman geç kuralı ekle', '+ Add always-pass rule')}
|
||||||
|
</Button>
|
||||||
|
</SettingsSection>
|
||||||
|
<SettingsSection icon={Link} title={text('Paralellik ve bekleme', 'Concurrency and waiting')}>
|
||||||
|
{number(
|
||||||
|
'maxConcurrent',
|
||||||
|
text('Eşzamanlı Jev isteği (0: yerel sınır yok)', 'Concurrent Jev requests (0: no local limit)'),
|
||||||
|
0,
|
||||||
|
512
|
||||||
|
)}
|
||||||
|
{number(
|
||||||
|
'queueWaitSeconds',
|
||||||
|
text('Sırada en fazla bekleme (saniye)', 'Maximum queue wait (seconds)'),
|
||||||
|
1,
|
||||||
|
3600
|
||||||
|
)}
|
||||||
|
{number('timeoutSeconds', text('Jev yanıt zaman aşımı (saniye)', 'Jev response timeout (seconds)'), 1, 3600)}
|
||||||
|
<ListRow
|
||||||
|
action={
|
||||||
|
<Select
|
||||||
|
onValueChange={value => change('failureAction', value as 'pass' | 'next')}
|
||||||
|
value={draft.failureAction}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label={text('Jev’e ulaşılamadığında', 'When Jev is unavailable')}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pass">{text('Mevcut cevabı ilet', 'Pass the current answer')}</SelectItem>
|
||||||
|
<SelectItem value="next">{text('Sıradaki katmanı dene', 'Try the next layer')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
}
|
||||||
|
title={text('Jev’e ulaşılamadığında', 'When Jev is unavailable')}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
<Button onClick={() => void save()}>{busy ? text('Kaydediliyor…', 'Saving…') : text('Kaydet', 'Save')}</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setDraft(structuredClone(defaultSettings) as StagedTransitionSettings)
|
||||||
|
setMessage(
|
||||||
|
text(
|
||||||
|
'Varsayılanlar forma yüklendi. Uygulamak için Kaydet’e basın.',
|
||||||
|
'Defaults loaded into the form. Save to apply.'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setError(false)
|
||||||
|
}}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{text('Varsayılanları yükle', 'Load defaults')}
|
||||||
|
</Button>
|
||||||
|
{message && (
|
||||||
|
<p className="py-3" role={error ? 'alert' : 'status'}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
</SettingsContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export type SettingsView =
|
|||||||
| 'plugins'
|
| 'plugins'
|
||||||
| 'providers'
|
| 'providers'
|
||||||
| 'sessions'
|
| 'sessions'
|
||||||
|
| 'staged-transition'
|
||||||
| `config:${string}`
|
| `config:${string}`
|
||||||
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
||||||
|
|
||||||
|
|||||||
Vendored
+3
@@ -3,6 +3,7 @@ import type { TranslucencyState } from '@hermes/shared/translucency'
|
|||||||
|
|
||||||
import type { CodexLinkPatch, CodexLinkStatus } from '../electron/codex-link-types'
|
import type { CodexLinkPatch, CodexLinkStatus } from '../electron/codex-link-types'
|
||||||
import type { PoolLimits } from '../electron/pool-limits'
|
import type { PoolLimits } from '../electron/pool-limits'
|
||||||
|
import type { StagedTransitionPatch, StagedTransitionState } from '../electron/staged-transition-types'
|
||||||
|
|
||||||
import type { WakeIndicatorState } from './lib/wake-indicator'
|
import type { WakeIndicatorState } from './lib/wake-indicator'
|
||||||
import type {
|
import type {
|
||||||
@@ -340,6 +341,8 @@ declare global {
|
|||||||
* use the IDE's models. `get` only reads; `set` applies and returns the
|
* use the IDE's models. `get` only reads; `set` applies and returns the
|
||||||
* state that actually resulted. Windows only. */
|
* state that actually resulted. Windows only. */
|
||||||
codexLink?: {
|
codexLink?: {
|
||||||
|
getStagedTransition: () => Promise<StagedTransitionState>
|
||||||
|
setStagedTransition: (patch: StagedTransitionPatch) => Promise<StagedTransitionState>
|
||||||
get: () => Promise<CodexLinkStatus>
|
get: () => Promise<CodexLinkStatus>
|
||||||
set: (patch: CodexLinkPatch) => Promise<CodexLinkStatus>
|
set: (patch: CodexLinkPatch) => Promise<CodexLinkStatus>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
"rootDir": "..",
|
"rootDir": "..",
|
||||||
"outDir": "build/electron-types"
|
"outDir": "build/electron-types"
|
||||||
},
|
},
|
||||||
"include": ["electron", "../shared/src/data-url-read-max.ts", "../shared/src/translucency.ts"],
|
"include": [
|
||||||
|
"electron",
|
||||||
|
"assets/codex-bridge/jev-settings-defaults.json",
|
||||||
|
"../shared/src/data-url-read-max.ts",
|
||||||
|
"../shared/src/translucency.ts"
|
||||||
|
],
|
||||||
"exclude": ["src", "electron/**/*.e2e.mts"]
|
"exclude": ["src", "electron/**/*.e2e.mts"]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user