diff --git a/apps/desktop/assets/codex-bridge/README.md b/apps/desktop/assets/codex-bridge/README.md index 13a0037..936c761 100644 --- a/apps/desktop/assets/codex-bridge/README.md +++ b/apps/desktop/assets/codex-bridge/README.md @@ -27,6 +27,24 @@ Run: `python bridge.py --home --hermes-home `. The IDE wr Tests: `python -m unittest discover` in this directory. +## Version 8 (IDE beta.27): changing the model by saying so + +Measured on a live voice chat (11 Oct 2026): a model picked on the phone reaches this machine only with a typed +message that STARTS a turn; a message that joins a running turn (including the lone `.`) does not carry it. +`model_gecis.py` therefore handles the change in the bridge. A short user message such as "modeli gemini flash low +yap" (spoken through the voice assistant or typed) is matched against the model list; the chat's requests are then +routed to that model (`model-yonlendirme.json`, keyed by `prompt_cache_key`) until Codex sends a different model +for the chat, i.e. until the user picks another one in the list. + +- Idle chat: the bridge answers itself ("Sohbetin modeli artık X ..."), which also tells the voice assistant. +- Running turn: the command is replaced by a handover note and the SAME turn continues on the new model. +- "seçili model hangisi" / "ana model kim" is answered by the bridge with the model actually in use. +- An ambiguous name changes nothing and lists the closest names; a name matching no model is not a command. +- A lone `.` that joins a running turn is dropped instead of answered (answering it cut the work short). +- Handled commands left in the history are turned into notes so a later model does not answer them again. + +Setting: `sesle_model` (default on). + ## Version 7 (IDE beta.26): external models behave like subscription models Measured on 10 Oct 2026 with the unmodified Codex binary (`codex exec`, same conversation, model switched per turn). diff --git a/apps/desktop/assets/codex-bridge/bridge.py b/apps/desktop/assets/codex-bridge/bridge.py index f5af371..467f28f 100644 --- a/apps/desktop/assets/codex-bridge/bridge.py +++ b/apps/desktop/assets/codex-bridge/bridge.py @@ -27,8 +27,9 @@ import ide_catalog import ozet_kopru import pencere import yarim +import model_gecis -SURUM = "7" +SURUM = "8" HERE = os.path.dirname(os.path.abspath(__file__)) # Codex sends request bodies zstd-compressed. Python gained zstd in 3.14; for older @@ -583,7 +584,12 @@ def nokta_mesaji_mi(req): girdi = req.get("input") if not isinstance(girdi, list) or not girdi: return False - son = girdi[-1] + son = None + for oge in reversed(girdi): # kancalarin kullanici mesajinin ardina ekledigi talimat mesajlari atlanir + if isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") in ("developer", "system"): + continue + son = oge + break if not (isinstance(son, dict) and son.get("type", "message") == "message" and son.get("role") == "user"): return False icerik = son.get("content") @@ -651,6 +657,28 @@ def decompress(body, enc): return body OZET_FRENI = ozet_kopru.DonguFreni() +YONLENDIRME = model_gecis.Yonlendirme(HOME) +_BILINEN = {"kaynak": None, "liste": []} + +def bilinen_modeller(): + """Codex'e en son verilen listedeki (slug, gorunen ad) ciftleri: abonelik modelleri + eklenenler.""" + son = max(LAST_MODELS.values(), key=lambda v: str(v.get("at") or ""), default=None) if LAST_MODELS else None + govde = son.get("body") if son else None + if govde is not None and _BILINEN["kaynak"] is not govde: + try: + liste = [(m["slug"], m.get("display_name") or m["slug"]) for m in json.loads(govde).get("models", []) + if isinstance(m, dict) and m.get("slug")] + except (ValueError, AttributeError, TypeError): + liste = [] + _BILINEN["kaynak"], _BILINEN["liste"] = govde, liste + liste = list(_BILINEN["liste"]) + gorulen = {slug for slug, _ in liste} + liste += [(m["slug"], m.get("display_name") or m["slug"]) for m in EXTRA_MODELS + if m.get("slug") and m["slug"] not in gorulen] + return liste + +def model_adi(slug): + return next((ad for kimlik, ad in bilinen_modeller() if kimlik == slug), None) or slug class H(http.server.BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" @@ -982,6 +1010,54 @@ class H(http.server.BaseHTTPRequestHandler): self.wfile.write(out) log(f"{tag} bitti status=200 {raw_len} bayt (gonderilen {len(out)}, {enc_out or 'sikistirmasiz'}), {changed} model girdisi degistirildi/eklendi") + def _yerel_yanit(self, slug, metin): + govde = hazir_yaniti(slug, metin) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Content-Length", str(len(govde))) + self.end_headers() + self.wfile.write(govde) + + def _model_komutu(self, n, req, sohbet, gelen): + """Son kullanici mesaji "modeli X yap" / "hangi model" ise isler. Yerel cevap metni doner; + None: istek (gerekirse degistirilmis olarak) modele gider.""" + metin, sira, sesli = model_gecis.son_kullanici_metni(req) + komut = model_gecis.komut_coz(metin) if metin else None + if not komut: + return None + etkin = YONLENDIRME.al(sohbet, gelen) or gelen + ortada = model_gecis.tur_ortasi(req, sira) + if komut["tur"] == "sor": + if ortada: + return None + log(f"[{n} {gelen}] model sorusu kopruden cevaplandi: {etkin}") + return f"Sohbetin modeli şu an {model_adi(etkin)}." + sonuc = model_gecis.eslestir(komut["ad"], bilinen_modeller()) + if not sonuc: + return None # ad hicbir modele benzemiyor: komut degil, modele gider + if "adaylar" in sonuc: + soru = ("Hangi modeli istediğini tek modele bağlayamadım, model değişmedi. En yakınlar: " + + ", ".join(sonuc["adaylar"]) + ". Adını biraz daha tam söyler misin?") + log(f"[{n} {gelen}] model komutu belirsiz: {komut['ad']} -> {sonuc['adaylar']}") + if not ortada: + return soru + model_gecis.metni_degistir(req, sira, "[Köprü notu: kullanıcı model değiştirmek istedi ama ad belirsizdi; " + "model değişmedi. İşine devam et; cevabının sonunda şunu ilet: " + soru + "]") + return None + hedef = sonuc["slug"] + if hedef.startswith("ide/") and MODEL_MAP.get(hedef) is None and "aiturk-ide" not in ROUTES: + return "AITURK bağlantısı şu an kapalı; bu modele geçemedim, model değişmedi." + YONLENDIRME.koy(sohbet, gelen, hedef) + log(f"[{n} {gelen}] model komutu: {model_adi(etkin)} -> {sonuc['ad']}" + f" ({'tur ortasi, is devrediliyor' if ortada else 'bosta'}{', sesli' if sesli else ''})") + if hedef == etkin: + return None if ortada else f"Sohbetin modeli zaten {sonuc['ad']}." + if ortada: + model_gecis.metni_degistir(req, sira, model_gecis.devir_notu(sonuc["ad"], model_adi(etkin))) + return None + return (f"Sohbetin modeli artık {sonuc['ad']}. Önceki model {model_adi(etkin)} idi. " + f"Bundan sonraki işleri {sonuc['ad']} yapacak; hangi model diye sorulursa bunu söyle.") + def _responses(self, n, path, body): try: req = json.loads(decompress(body, self.headers.get("Content-Encoding"))) @@ -989,21 +1065,42 @@ class H(http.server.BaseHTTPRequestHandler): log(f"[{n} responses] govde cozulemedi: {e}") return self._send_json(400, {"error": {"message": f"bridge: govde cozulemedi: {e}"}}) slug = req.get("model", "") + gelen = slug # Codex'in bu sohbet icin gonderdigi ad (listeden secilen model) + govde_degisti = False # istek govdesi kopruce degistirildi: abonelik yolunda yeniden yazilir + once = len(req.get("input") or []), json.dumps((req.get("input") or [None])[-3:], ensure_ascii=False, sort_keys=True) + sohbet = str(req.get("prompt_cache_key") or "") + if CFG.get("sesle_model", True) and sohbet: + try: + yerel = self._model_komutu(n, req, sohbet, gelen) + except Exception as e: + yerel = None + log(f"[{n} {gelen}] model komutu atlandi: {type(e).__name__}: {e}") + if yerel is not None: + return self._yerel_yanit(gelen, yerel) + try: + if model_gecis.gecmisi_temizle(req, bilinen_modeller()): + govde_degisti = True + except Exception as e: + log(f"[{n} {gelen}] gecmis komut temizligi atlandi: {type(e).__name__}: {e}") + hedef = YONLENDIRME.al(sohbet, gelen) + if hedef and hedef != gelen: + req["model"] = slug = hedef + log(f"[{n} {gelen}] sohbet yonlendirmesi: istek {hedef} modeline gidiyor") mapping = MODEL_MAP.get(slug) + if CFG.get("nokta_karsila", True) and nokta_mesaji_mi(req) and model_gecis.tur_ortasi(req, model_gecis.son_sira(req["input"])): + # Calisan turun icine dusen nokta secimi tasimaz (olcum 10 Ekim 2026) ve cevaplanirsa isi yarida + # keser. Mesaj cikarilir, model isine devam eder; kullanici model bosalinca yeniden yazar. + del req["input"][model_gecis.son_sira(req["input"]):] + govde_degisti = True + log(f"[{n} {slug}] tur ortasinda gelen nokta cikarildi; calisan is kesilmedi") + if slug != gelen or once != (len(req.get("input") or []), json.dumps((req.get("input") or [None])[-3:], ensure_ascii=False, sort_keys=True)): + govde_degisti = True if CFG.get("nokta_karsila", True) and nokta_mesaji_mi(req): # Model degisimini uygulamak icin yazilan tek "." modele gonderilmez: hicbir model onu onay ya da # istek sanip is yapmaz, yavas modelde asil soru beklemez. Secilen modelin adiyla aninda donulur. - ad = next((m.get("display_name") for m in EXTRA_MODELS if m.get("slug") == slug), None) or slug - govde = hazir_yaniti(slug, f"{ad} hazır.") log(f"[{n} {slug}] nokta mesaji kopruden karsilandi (modele gonderilmedi)") - self.send_response(200) - self.send_header("Content-Type", "text/event-stream") - self.send_header("Content-Length", str(len(govde))) - self.end_headers() - self.wfile.write(govde) - return + return self._yerel_yanit(gelen, f"{model_adi(slug)} hazır. Sohbetin modeli artık {model_adi(slug)}.") # Ayni sohbetin onceki cevabi yarida kesildiyse (yarim.py) metni bu istege not olarak eklenir. - sohbet = str(req.get("prompt_cache_key") or "") yarim_eklenen = 0 if CFG.get("yarim_cevap", True): try: @@ -1037,7 +1134,7 @@ class H(http.server.BaseHTTPRequestHandler): # abonelik modeli: chatgpt.com'a oldugu gibi fixed = fix_tool_ids(req) atilan = drop_foreign_items(req) - degisti = bool(yarim_eklenen) + degisti = bool(yarim_eklenen) or govde_degisti ozet_akisi = None if ozet_acik: # Ozet bagdastirici (ozet_kopru.py; 9 Ekim 2026'da yalniz bu parca geri acildi): koprunun urettigi diff --git a/apps/desktop/assets/codex-bridge/model_gecis.py b/apps/desktop/assets/codex-bridge/model_gecis.py new file mode 100644 index 0000000..49479bd --- /dev/null +++ b/apps/desktop/assets/codex-bridge/model_gecis.py @@ -0,0 +1,259 @@ +"""Sesle ya da yaziyla "modeli sunu yap" komutu ve sohbet basina model yonlendirmesi (10 Ekim 2026). + +Olcum (sesli sohbet 01a12780): telefonda secilen model bilgisayara yalnizca YENI TUR baslatan yazili mesajla +ulasiyor. Calisan tura eklenen mesaj (nokta dahil) secimi tasimiyor; dort denemenin ucunde secim kayboldu. +Codex'in pencereler arasi kanali da sohbet bir pencerede acik degilse istegi kabul etmiyor. Bu yuzden degisiklik +koprude yapilir: kullanici "modeli gemini flash low yap" der (sesli asistan bunu her soz gibi ana modele iletir), +kopru cumleyi tanir, o sohbetin isteklerini secilen modele yonlendirir ve cevabi kendisi verir. + +- Model bostayken: kopru yerel cevap verir ("Sohbetin modeli artik ..."). +- Model calisirken (tur ortasi): komut mesaji kisa bir devir notuna cevrilir ve AYNI tur yeni modelle surer; + yarim kalan is kesilmez, yeni model kaldigi yerden devam eder. +- Kullanici sonradan listeden baska model secerse (Codex'in gonderdigi ad degisir) yonlendirme kendiliginden kalkar. + +Ad bulunamazsa mesaj komut sayilmaz, modele oldugu gibi gider. Birden cok aday varsa degisiklik yapilmaz, adaylar sorulur. +""" +import json +import os +import re +import threading +import time + +DOSYA = "model-yonlendirme.json" +OMUR_SN = 7 * 24 * 3600 +EN_COK_KAYIT = 200 +EN_COK_KELIME = 14 +_KILIT = threading.Lock() + +_TR = str.maketrans("çğıöşüâîû", "cgiosuaiu") +FIILLER = ("gec", "degis", "yap", "sec", "kullan", "cevir", "olsun", "ayarla") +SORU = ("hangi", "hangisi", "kim", "kimdir") +ATLA = {"model", "modeli", "modelini", "modele", "modeline", "modelin", "modelle", "modeller", "su", "bu", "o", "bir", + "olarak", "ile", "lutfen", "artik", "ana", "sohbet", "sohbetin", "chat", "chatin", "chatte", "simdi", "hadi", + "bana", "icin", "de", "da", "ve", "mi", "misin", "musun", "mu", "yeni", "diger", "baska", "olan", "codex", + "ya", "e", "a", "i", "u", "yi", "ye", "na", "ne", "nu", "seni", "sen", "biz", "onu", "ona"} +# Konusmadan yaziya ceviride sik gorulen yazimlar -> model adindaki parca +TAKMA = {"cloud": "claude", "klod": "claude", "klaud": "claude", "claud": "claude", "opus": "opus", "opüs": "opus", + "cemini": "gemini", "jemini": "gemini", "cemin": "gemini", "flas": "flash", "fles": "flash", + "kuven": "qwen", "kiven": "qwen", "kuen": "qwen", "kven": "qwen", "kodeks": "codex", + "dusuk": "low", "yuksek": "high", "orta": "medium", "sonet": "sonnet", "haiku": "haiku", + "sansursuz": "uncensored", "ansansird": "uncensored", "dusunen": "thinking", "dusunmeli": "thinking", + "ciptii": "gpt", "cipiti": "gpt", "gipiti": "gpt"} + + +# Listede kisa yazilan saglayici -> konusurken soylenen ad +SAGLAYICI = {"cc": ("claude",), "agy": ("gemini", "antigravity"), "ag": ("gemini", "antigravity"), "cx": ("codex", "gpt")} + + +def duz(metin): + return str(metin or "").lower().translate(_TR) + + +def parcala(metin): + """Harf ve rakam obeklerine ayirir: "agy/gemini-3.8-flash-low" -> agy gemini 3 8 flash low.""" + return [TAKMA.get(p, p) for p in re.findall(r"[a-z]+|\d+", duz(metin))] + + +def son_sira(girdi): + """Son kullanici mesajinin sirasi. Arkasindaki talimat (developer/system) mesajlari atlanir: kancalar + kullanici mesajinin ardina baglam ekler (olcum 10 Ekim 2026). Baska bir oge varsa -1.""" + if not isinstance(girdi, list): + return -1 + for i in range(len(girdi) - 1, -1, -1): + oge = girdi[i] + if isinstance(oge, dict) and oge.get("type", "message") == "message": + if oge.get("role") == "user": + return i + if oge.get("role") in ("developer", "system"): + continue + return -1 + return -1 + + +def son_kullanici_metni(req): + """(metin, sira, sesli). Son girdi kullanici metin mesaji degilse (None, -1, False). + Sesli aktarimda () yalnizca icindeki soz alinir.""" + girdi = req.get("input") + sira = son_sira(girdi) + if sira < 0: + return None, -1, False + son = girdi[sira] + icerik = son.get("content") + if isinstance(icerik, str): + metin = icerik + elif isinstance(icerik, list) and icerik and all(isinstance(p, dict) and p.get("type") in ("input_text", "text") + for p in icerik): + metin = "".join(str(p.get("text") or "") for p in icerik) + else: + return None, -1, False + m = re.search(r".*?(.*?)", metin, re.S) + if m: + return m.group(1).strip(), sira, True + if "<" in metin and ">" in metin: + return None, -1, False # baska bir sarmalli sistem mesaji; komut aranmaz + return metin.strip(), sira, False + + +def komut_coz(metin): + """None | {"tur": "gec", "ad": [parcalar]} | {"tur": "sor"}.""" + kelimeler = re.findall(r"[a-z0-9]+", duz(metin)) + if not kelimeler or len(kelimeler) > EN_COK_KELIME or not any(k.startswith("model") for k in kelimeler): + return None + if len(kelimeler) <= 8 and any(k in SORU for k in kelimeler): + return {"tur": "sor"} + fiil = [k for k in kelimeler if k.startswith(FIILLER) and not k.startswith("model")] + if fiil: + ad = [TAKMA.get(k, k) for k in kelimeler if k not in ATLA and k not in fiil] + ad = [p for k in ad for p in re.findall(r"[a-z]+|\d+", k)] + return {"tur": "gec", "ad": ad} if ad else None + return None + + +def _uyar(sorgu, parca): + """Turkce ek almis soz ("geminiye", "opusa") model parcasiyla baslar.""" + if sorgu == parca: + return True + return len(parca) >= 3 and sorgu.startswith(parca) and len(sorgu) - len(parca) <= 4 and not sorgu.isdigit() + + +def eslestir(ad, modeller): + """ad: sorgu parcalari; modeller: [(slug, gorunen ad)]. + Doner: {"slug", "ad"} (tek aday) | {"adaylar": [gorunen adlar]} (belirsiz) | None (ad bir modele benzemiyor).""" + if not ad: + return None + puanli = [] + for slug, gorunen in modeller: + parcalar = parcala(gorunen) or parcala(slug) + kume = set(parcalar) | set(parcala(slug)) + for kisa, uzunlar in SAGLAYICI.items(): + if kisa in kume: + kume.update(uzunlar) + tutan = sum(1 for s in ad if any(_uyar(s, p) for p in kume)) + harf = sum(1 for s in ad if not s.isdigit() and any(_uyar(s, p) for p in kume)) + if harf: # yalniz rakam tutmasi ("3", "5") esleme sayilmaz + puanli.append((tutan, -len(parcalar), slug, gorunen)) + if not puanli: + return None + en = max(p[0] for p in puanli) + if en < len(ad) and en < max(2, (len(ad) * 2 + 2) // 3): + return None # sozun cogu hicbir modele uymuyor: komut degil + iyi = sorted((p for p in puanli if p[0] == en), key=lambda p: (-p[1], p[3])) + if len(iyi) == 1 or iyi[0][1] != iyi[1][1]: + return {"slug": iyi[0][2], "ad": iyi[0][3]} + return {"adaylar": [p[3] for p in iyi[:4]]} + + +_ARAC = {"function_call", "function_call_output", "custom_tool_call", "custom_tool_call_output", "reasoning", + "local_shell_call", "local_shell_call_output", "tool_search_call", "tool_search_output"} + + +def tur_ortasi(req, sira): + """Mesaj calisan bir turun icine mi dustu? Geriye dogru, kullanici/talimat mesajlarini atlayarak bakilir: + once arac ogesi ya da ara (commentary) mesaj gelirse tur suruyordur; once son cevap gelirse tur yeni baslamistir.""" + girdi = req.get("input") or [] + for oge in reversed(girdi[:max(sira, 0)]): + if not isinstance(oge, dict): + continue + tur = oge.get("type", "message") + if tur in _ARAC: + return True + if tur == "message": + if oge.get("role") == "assistant": + return oge.get("phase") == "commentary" + continue + return False + + +def metni_degistir(req, sira, yeni): + """sira'daki kullanici mesajinin metnini yeni ile degistirir (sesli aktarimda yalnizca icini).""" + oge = req["input"][sira] + icerik = oge.get("content") + eski = icerik if isinstance(icerik, str) else "".join(str(p.get("text") or "") for p in icerik) + if "" in eski: + metin = re.sub(r"().*?()", lambda m: m.group(1) + yeni + m.group(2), eski, count=1, flags=re.S) + else: + metin = yeni + oge["content"] = metin if isinstance(icerik, str) else [{"type": icerik[0].get("type", "input_text"), "text": metin}] + + +def gecmisi_temizle(req, modeller): + """Gecmisteki (son mesaj disindaki) islenmis "modeli X yap" komutlarini kisa nota cevirir; yoksa sonraki + model ayni cumleyi yeniden gorup "modeli ben degistiremem" diye cevap veriyor (olcum 11 Ekim 2026). + Doner: degistirilen mesaj sayisi.""" + girdi = req.get("input") + if not isinstance(girdi, list): + return 0 + son = son_sira(girdi) + n = 0 + for i, oge in enumerate(girdi): + if i == son or not (isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") == "user"): + continue + metin, _, _ = son_kullanici_metni({"input": [oge]}) + if not metin or "model" not in duz(metin) or metin.startswith("[Köprü notu"): + continue + komut = komut_coz(metin) + sonuc = eslestir(komut["ad"], modeller) if komut and komut["tur"] == "gec" else None + if sonuc and "slug" in sonuc: + metni_degistir(req, i, f"[Köprü notu: kullanıcı burada sohbetin modelini {sonuc['ad']} olarak değiştirdi; " + "değişiklik köprü tarafından yapıldı, senin bir şey yapman gerekmiyor.]") + n += 1 + return n + + +def devir_notu(yeni_ad, eski_ad): + return (f"[Köprü notu: kullanıcı bu sohbetin modelini değiştirdi. Önceki model {eski_ad} idi; şu andan itibaren " + f"cevap veren model sensin ({yeni_ad}). Yarım kalan işi kaldığı yerden sürdür, baştan başlama. " + f"Cevabının ilk cümlesinde kısaca \"{yeni_ad} olarak devam ediyorum\" de.]") + + +class Yonlendirme: + """sohbet anahtari -> {"gorulen": Codex'in gonderdigi ad, "hedef": yonlenen ad, "zaman"}; diske yazilir.""" + + def __init__(self, home): + self.yol = os.path.join(home, DOSYA) if home else None + self.veri = {} + if self.yol: + try: + with open(self.yol, encoding="utf-8") as f: + okunan = json.load(f) + if isinstance(okunan, dict): + self.veri = {k: v for k, v in okunan.items() if isinstance(v, dict) and v.get("hedef")} + except (OSError, ValueError): + pass + + def _yaz(self): + if not self.yol: + return + try: + gecici = self.yol + ".tmp" + with open(gecici, "w", encoding="utf-8") as f: + json.dump(self.veri, f, ensure_ascii=False) + os.replace(gecici, self.yol) + except OSError: + pass + + def al(self, sohbet, gelen, simdi=None): + """Bu sohbet icin yonlenen ad; yoksa None. Codex baska bir ad gonderiyorsa (kullanici listeden yeni + model secti) ya da kayit eskidiyse yonlendirme silinir.""" + simdi = simdi or time.time() + with _KILIT: + k = self.veri.get(sohbet) + if not k: + return None + if k.get("gorulen") != gelen or simdi - float(k.get("zaman") or 0) > OMUR_SN: + del self.veri[sohbet] + self._yaz() + return None + return k["hedef"] + + def koy(self, sohbet, gelen, hedef, simdi=None): + simdi = simdi or time.time() + with _KILIT: + if hedef == gelen: + self.veri.pop(sohbet, None) + else: + self.veri[sohbet] = {"gorulen": gelen, "hedef": hedef, "zaman": simdi} + if len(self.veri) > EN_COK_KAYIT: + for eski in sorted(self.veri, key=lambda a: self.veri[a].get("zaman") or 0)[:-EN_COK_KAYIT]: + del self.veri[eski] + self._yaz() diff --git a/apps/desktop/assets/codex-bridge/test_model_gecis.py b/apps/desktop/assets/codex-bridge/test_model_gecis.py new file mode 100644 index 0000000..d0734e7 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/test_model_gecis.py @@ -0,0 +1,106 @@ +"""Bridge 8: "modeli X yap" is handled by the bridge; a per-chat route survives until the user picks another model.""" +import tempfile +import unittest + +import model_gecis as g + +MODELS = [("gpt-6-sol", "GPT-6-Sol"), ("gpt-6-astra", "GPT-6-Astra"), ("gpt-6.1-sol", "GPT-6.1-Sol"), + ("ide/9router-ag-gemini-3.8-flash-low", "agy/gemini-3.8-flash-low"), + ("ide/9router-ag-gemini-3.8-flash-high", "agy/gemini-3.8-flash-high"), + ("ide/9router-cc-claude-opus-5-5", "cc/opus-5-5"), ("ide/9router-cc-claude-opus-5-5-nt", "cc/opus-5-5 (no-think)"), + ("ide/9router-nano-z-ai-glm-5.3-flash-uncensored", "nano/glm-5.3-flash-uncensored"), + ("ide/agnes-agnes-2.0-flash", "agnes/agnes-2.0-flash"), ("ide/agnes-agnes-2.5-flash", "agnes/agnes-2.5-flash")] + + +def user(text): + return {"type": "message", "role": "user", "content": [{"type": "input_text", "text": text}]} + + +def voice(text): + return user(f"\n {text}\n user: x\n") + + +def pick(text): + command = g.komut_coz(text) + return g.eslestir(command["ad"], MODELS) if command and command["tur"] == "gec" else command + + +class Komut(unittest.TestCase): + def test_spoken_names_resolve_to_one_model(self): + for text, slug in [("modeli gemini flash low yap", "ide/9router-ag-gemini-3.8-flash-low"), + ("Modeli Gemini 3.8 Flash High'a geçir", "ide/9router-ag-gemini-3.8-flash-high"), + ("modeli klod opus yap", "ide/9router-cc-claude-opus-5-5"), + ("modeli opusa geç", "ide/9router-cc-claude-opus-5-5"), + ("modeli gpt 6 astra yap", "gpt-6-astra"), ("model geç gpt 6.1 sol", "gpt-6.1-sol"), + ("modeli glm flaş sansürsüz yap", "ide/9router-nano-z-ai-glm-5.3-flash-uncensored")]: + self.assertEqual((pick(text) or {}).get("slug"), slug, text) + + def test_ambiguous_name_lists_candidates_and_changes_nothing(self): + self.assertEqual(pick("modeli agnes yap"), {"adaylar": ["agnes/agnes-2.0-flash", "agnes/agnes-2.5-flash"]}) + + def test_ordinary_sentences_are_not_commands(self): + for text in ("Masaüstünde kaç klasör var", "bu modeli kullanarak bir site yap", "yapay zeka modeli nedir", + "modeli değiştir", "hangi modeli önerirsin bu iş için bana söyle bakalım lütfen", + "model " + "çok uzun bir cümle " * 5 + "yap"): + self.assertFalse(pick(text) and "slug" in pick(text), text) + self.assertEqual(g.komut_coz("seçili model hangisi"), {"tur": "sor"}) + self.assertEqual(g.komut_coz("bu chatte ana model kim"), {"tur": "sor"}) + + def test_voice_delegation_yields_only_the_spoken_input(self): + text, index, spoken = g.son_kullanici_metni({"input": [user("a"), voice("modeli opus yap")]}) + self.assertEqual((text, index, spoken), ("modeli opus yap", 1, True)) + self.assertEqual(g.son_kullanici_metni({"input": [user("x")]})[0], None) + self.assertEqual(g.son_kullanici_metni({"input": [{"type": "function_call_output", "output": "x"}]})[0], None) + + +class TurOrtasi(unittest.TestCase): + def test_message_after_tool_items_is_mid_turn_and_after_final_answer_is_not(self): + final = {"type": "message", "role": "assistant", "phase": "final_answer", "content": "ok"} + call = {"type": "function_call", "call_id": "c", "name": "shell", "arguments": "{}"} + out = {"type": "function_call_output", "call_id": "c", "output": "x"} + self.assertFalse(g.tur_ortasi({"input": [user("a"), final, user(".")]}, 2)) + self.assertTrue(g.tur_ortasi({"input": [user("a"), call, out, user(".")]}, 3)) + self.assertTrue(g.tur_ortasi({"input": [user("a"), call, out, voice("bekliyorum"), user(".")]}, 4)) + self.assertTrue(g.tur_ortasi({"input": [user("a"), {"type": "message", "role": "assistant", "phase": "commentary", + "content": "bakıyorum"}, user(".")]}, 2)) + self.assertFalse(g.tur_ortasi({"input": [user(".")]}, 0)) + + def test_handover_note_replaces_only_the_spoken_input(self): + req = {"input": [voice("modeli opus yap")]} + g.metni_degistir(req, 0, g.devir_notu("cc/opus-5-5", "GPT-6-Sol")) + text = req["input"][0]["content"][0]["text"] + self.assertIn("", text) + self.assertIn("cc/opus-5-5 olarak devam ediyorum", text) + self.assertNotIn("modeli opus yap", text) + + +class Gecmis(unittest.TestCase): + def test_handled_commands_in_history_become_notes_but_the_last_message_is_left_alone(self): + req = {"input": [user("modeli gemini flash low yap"), user("Masaüstünde kaç dosya var"), + voice("modeli opus yap"), user("modeli gpt 6 astra yap")]} + self.assertEqual(g.gecmisi_temizle(req, MODELS), 2) + self.assertIn("agy/gemini-3.8-flash-low olarak değiştirdi", req["input"][0]["content"][0]["text"]) + self.assertEqual(req["input"][1]["content"][0]["text"], "Masaüstünde kaç dosya var") + self.assertIn("", req["input"][2]["content"][0]["text"]) + self.assertEqual(req["input"][3]["content"][0]["text"], "modeli gpt 6 astra yap") + self.assertEqual(g.gecmisi_temizle(req, MODELS), 0) + + +class Yon(unittest.TestCase): + def test_route_lasts_until_the_user_picks_another_model_in_the_list(self): + with tempfile.TemporaryDirectory() as home: + y = g.Yonlendirme(home) + y.koy("chat", "gpt-6-sol", "ide/x", simdi=1000) + self.assertEqual(g.Yonlendirme(home).al("chat", "gpt-6-sol", simdi=1001), "ide/x") # survives a restart + self.assertIsNone(y.al("other", "gpt-6-sol", simdi=1001)) + self.assertIsNone(y.al("chat", "gpt-6-astra", simdi=1002)) # user picked another + self.assertIsNone(y.al("chat", "gpt-6-sol", simdi=1003)) # and it stays cleared + y.koy("chat", "gpt-6-sol", "ide/x", simdi=1000) + y.koy("chat", "gpt-6-sol", "gpt-6-sol", simdi=1001) # back to the picked one + self.assertIsNone(y.al("chat", "gpt-6-sol", simdi=1002)) + y.koy("old", "a", "b", simdi=1000) + self.assertIsNone(y.al("old", "a", simdi=1000 + g.OMUR_SN + 1)) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/desktop/electron/codex-link-config.ts b/apps/desktop/electron/codex-link-config.ts index 0218274..91a511f 100644 --- a/apps/desktop/electron/codex-link-config.ts +++ b/apps/desktop/electron/codex-link-config.ts @@ -27,7 +27,7 @@ export const CODEX_LINK_REFRESH_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama export const CODEX_LINK_MARKER = '# AITURK IDE Codex Baglama' // Must equal SURUM in assets/codex-bridge/bridge.py; a running bridge that reports // another version is restarted so an updated IDE never keeps an old bridge alive. -export const CODEX_BRIDGE_VERSION = '7' +export const CODEX_BRIDGE_VERSION = '8' export const CODEX_LINK_LINE = `openai_base_url = "${CODEX_LINK_ORIGIN}/backend-api/codex" ${CODEX_LINK_MARKER}` export const CODEX_LINK_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8ba1e05..c942ee0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "aiturk-ide", "productName": "AITURK IDE", "private": true, - "version": "1.0.0-beta.26", + "version": "1.0.0-beta.27", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "author": "AITURK / TurkServis", "repository": { diff --git a/package-lock.json b/package-lock.json index 13ef359..9462ed4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ }, "apps/desktop": { "name": "aiturk-ide", - "version": "1.0.0-beta.26", + "version": "1.0.0-beta.27", "license": "MIT", "dependencies": { "@assistant-ui/core": "0.2.23",