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
|
||||
|
||||
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]
|
||||
|
||||
@@ -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 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'])
|
||||
@@ -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.
|
||||
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}"}})
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'
|
||||
// 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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
"to": "codex-bridge",
|
||||
"filter": [
|
||||
"jev-settings-defaults.json",
|
||||
"**/*.py",
|
||||
"**/*.pyd",
|
||||
"native-watch.exe",
|
||||
|
||||
@@ -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
|
||||
<PluginsSettings />
|
||||
) : activeView === 'codex-link' ? (
|
||||
<CodexLinkSettings />
|
||||
) : activeView === 'staged-transition' ? (
|
||||
<StagedTransitionSettingsView />
|
||||
) : (
|
||||
<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'
|
||||
| 'providers'
|
||||
| 'sessions'
|
||||
| 'staged-transition'
|
||||
| `config:${string}`
|
||||
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 { PoolLimits } from '../electron/pool-limits'
|
||||
import type { StagedTransitionPatch, StagedTransitionState } from '../electron/staged-transition-types'
|
||||
|
||||
import type { WakeIndicatorState } from './lib/wake-indicator'
|
||||
import type {
|
||||
@@ -340,6 +341,8 @@ declare global {
|
||||
* use the IDE's models. `get` only reads; `set` applies and returns the
|
||||
* state that actually resulted. Windows only. */
|
||||
codexLink?: {
|
||||
getStagedTransition: () => Promise<StagedTransitionState>
|
||||
setStagedTransition: (patch: StagedTransitionPatch) => Promise<StagedTransitionState>
|
||||
get: () => Promise<CodexLinkStatus>
|
||||
set: (patch: CodexLinkPatch) => Promise<CodexLinkStatus>
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
"rootDir": "..",
|
||||
"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"]
|
||||
}
|
||||
Reference in new issue
Block a user