diff --git a/ai.py b/ai.py index 775f75d..a5f7cbc 100644 --- a/ai.py +++ b/ai.py @@ -301,6 +301,148 @@ def fix_mojibake(s: str) -> str: except Exception: return s +def _message_to_plain_text(content) -> str: + if isinstance(content, str): + return content.strip() + + if isinstance(content, list): + parts = [] + for item in content: + if not isinstance(item, dict): + continue + if item.get("type") == "text": + txt = item.get("text") + if isinstance(txt, str) and txt.strip(): + parts.append(txt.strip()) + return "\n".join(parts).strip() + + return "" + + +def _extract_images_from_message(msg: dict) -> dict: + """ + OpenRouter / farklı provider cevaplarından image url veya base64 çıkar. + UI'nin finalize_ai_response() fonksiyonunun anlayacağı formatı döndürür. + """ + out = { + "type": "text", + "content": "", + } + + if not isinstance(msg, dict): + return out + + content = msg.get("content") + text_parts = [] + image_urls = [] + image_b64 = [] + + # 1) content string ise + if isinstance(content, str): + if content.strip(): + text_parts.append(content.strip()) + + # 2) content liste ise + elif isinstance(content, list): + for item in content: + if not isinstance(item, dict): + continue + + itype = str(item.get("type") or "").strip().lower() + + if itype == "text": + txt = item.get("text") + if isinstance(txt, str) and txt.strip(): + text_parts.append(txt.strip()) + + elif itype in ("image_url", "output_image"): + iu = item.get("image_url") + if isinstance(iu, str) and iu.strip(): + image_urls.append(iu.strip()) + elif isinstance(iu, dict): + u = iu.get("url") + if isinstance(u, str) and u.strip(): + image_urls.append(u.strip()) + + b64 = item.get("b64_json") + if isinstance(b64, str) and b64.strip(): + image_b64.append(b64.strip()) + + data = item.get("image") + if isinstance(data, str) and data.strip(): + image_b64.append(data.strip()) + + # 3) message level fallback alanlar + for key in ("image", "url"): + val = msg.get(key) + if isinstance(val, str) and val.strip(): + image_urls.append(val.strip()) + + one_b64 = msg.get("image_base64") + if isinstance(one_b64, str) and one_b64.strip(): + image_b64.append(one_b64.strip()) + + imgs = msg.get("images") + if isinstance(imgs, list): + for item in imgs: + if isinstance(item, str) and item.strip(): + image_urls.append(item.strip()) + elif isinstance(item, dict): + u = item.get("url") + if isinstance(u, str) and u.strip(): + image_urls.append(u.strip()) + + iu = item.get("image_url") + if isinstance(iu, str) and iu.strip(): + image_urls.append(iu.strip()) + elif isinstance(iu, dict): + uu = iu.get("url") + if isinstance(uu, str) and uu.strip(): + image_urls.append(uu.strip()) + + b64 = item.get("b64_json") + if isinstance(b64, str) and b64.strip(): + image_b64.append(b64.strip()) + + # uniq + def _uniq(seq): + seen = set() + out2 = [] + for x in seq: + if x not in seen: + seen.add(x) + out2.append(x) + return out2 + + image_urls = _uniq(image_urls) + image_b64 = _uniq(image_b64) + + out["content"] = "\n".join(text_parts).strip() + + if image_urls: + out["type"] = "image" + out["images"] = image_urls + + if image_b64: + out["type"] = "image" + out["images_base64"] = image_b64 + + if image_urls and image_b64: + out["type"] = "image" + + return out + + +def _is_image_generation_model(model_name: str) -> bool: + m = str(model_name or "").strip().lower() + return any(x in m for x in [ + "image", + "gpt-image", + "imagen", + "flux", + "recraft", + "stable-diffusion" + ]) def _pick_model(cfg: dict, chat_file: Path, cli_model: str | None) -> str: """ @@ -548,16 +690,17 @@ def main(): final_messages.append({"role": role, "content": last.get("content", "")}) # Payload + is_image_model = _is_image_generation_model(model_name) payload = { "model": model_name, "messages": final_messages, "max_tokens": 5120, - "stream": True + "stream": False if is_image_model else True } # Request (STREAMING) - reply_parts = [] - usage_data = None + reply = "" + usage_obj = None try: response = requests.post( @@ -570,64 +713,105 @@ def main(): }, json=payload, timeout=120, - stream=True + stream=not is_image_model ) if response.status_code != 200: sys.stderr.write(f"OpenRouter API ERROR ({response.status_code}): {response.text}\n") sys.exit(1) - # SSE'yi satır satır oku (en stabil yöntem) - for raw_line in response.iter_lines(decode_unicode=False, chunk_size=1024): - if not raw_line: - continue + if is_image_model: + data = response.json() - line = raw_line.strip() - if not line.startswith(b"data:"): - continue + usage_data = data.get("usage") + if usage_data: + prompt_t = int(usage_data.get("prompt_tokens", 0) or 0) + comp_t = int(usage_data.get("completion_tokens", 0) or 0) + total_t = int(usage_data.get("total_tokens", 0) or 0) - data_str = line[len(b"data:"):].strip() - if data_str == b"[DONE]": - break + usage_obj = { + "prompt_tokens": prompt_t, + "completion_tokens": comp_t, + "total_tokens": total_t + } + msg = {} try: - evt = json.loads(data_str.decode("utf-8", errors="replace")) - if "usage" in evt: - usage_data = evt["usage"] + msg = data["choices"][0]["message"] except Exception: - continue + msg = {} + + result_obj = _extract_images_from_message(msg) + + # hiç image yoksa düz text fallback + if not result_obj.get("content") and not result_obj.get("images") and not result_obj.get("images_base64"): + txt = _message_to_plain_text(msg.get("content")) + result_obj = { + "type": "text", + "content": fix_mojibake(txt) + } + + if usage_obj is not None: + result_obj["usage"] = usage_obj + + reply = json.dumps(result_obj, ensure_ascii=False) + + sys.stdout.write(reply) + sys.stdout.flush() + + else: + reply_parts = [] + usage_data = None + + for raw_line in response.iter_lines(decode_unicode=False, chunk_size=1024): + if not raw_line: + continue + + line = raw_line.strip() + if not line.startswith(b"data:"): + continue + + data_str = line[len(b"data:"):].strip() + if data_str == b"[DONE]": + break + + try: + evt = json.loads(data_str.decode("utf-8", errors="replace")) + if "usage" in evt: + usage_data = evt["usage"] + except Exception: + continue - delta = None - try: - delta = evt["choices"][0]["delta"].get("content") - except Exception: delta = None + try: + delta = evt["choices"][0]["delta"].get("content") + except Exception: + delta = None - if delta: - delta = fix_mojibake(delta) - reply_parts.append(delta) - sys.stdout.write(delta) - sys.stdout.flush() + if delta: + delta = fix_mojibake(delta) + reply_parts.append(delta) + sys.stdout.write(delta) + sys.stdout.flush() - reply = "".join(reply_parts).strip() - # token usage yazdır - usage_obj = None + reply = "".join(reply_parts).strip() - if usage_data: - prompt_t = int(usage_data.get("prompt_tokens", 0) or 0) - comp_t = int(usage_data.get("completion_tokens", 0) or 0) - total_t = int(usage_data.get("total_tokens", 0) or 0) + if usage_data: + prompt_t = int(usage_data.get("prompt_tokens", 0) or 0) + comp_t = int(usage_data.get("completion_tokens", 0) or 0) + total_t = int(usage_data.get("total_tokens", 0) or 0) - usage_obj = { - "prompt_tokens": prompt_t, - "completion_tokens": comp_t, - "total_tokens": total_t - } + usage_obj = { + "prompt_tokens": prompt_t, + "completion_tokens": comp_t, + "total_tokens": total_t + } - reply = fix_mojibake(reply) - if not reply: - sys.stderr.write("OpenRouter boş stream döndürdü.\n") - sys.exit(1) + + reply = fix_mojibake(reply) + if not reply: + sys.stderr.write("OpenRouter boş stream döndürdü.\n") + sys.exit(1) except requests.exceptions.Timeout: sys.stderr.write("İstek zaman aşımına uğradı (timeout).\n") @@ -640,30 +824,7 @@ def main(): except Exception as e: sys.stderr.write(f"AI ERROR: {str(e)}\n") sys.exit(1) - - # Save bot reply - # UI tarafı streaming için placeholder bot mesajı eklediyse, ona yaz. - if messages and isinstance(messages[-1], dict) and messages[-1].get("role") in ("bot", "assistant") and messages[-1].get("streaming"): - messages[-1]["role"] = "bot" - messages[-1]["content"] = reply - messages[-1].pop("streaming", None) - # usage JSON'a kaydet - if usage_obj is not None: - messages[-1]["usage"] = usage_obj - else: - bot_msg: Dict[str, Any] = {"role": "bot", "content": reply} - if usage_obj is not None: - bot_msg["usage"] = usage_obj - messages.append(bot_msg) - - try: - chat_file.write_text(json.dumps(messages, ensure_ascii=False, indent=2), encoding="utf-8") - except Exception as e: - sys.stderr.write(f"Chat yazılamadı: {e}\n") - sys.exit(1) - sys.exit(0) - if __name__ == "__main__": main() diff --git a/ui.py b/ui.py index 8c84a20..626b279 100644 --- a/ui.py +++ b/ui.py @@ -24,6 +24,8 @@ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) BASE_DIR = Path.home() / ".cache" / "capture-ai" CHAT_DIR = BASE_DIR / "chats" AI_SCRIPT = str(Path.home() / "capture-ai/ai.py") +GENERATED_DIR = BASE_DIR / "generated_images" +GENERATED_DIR.mkdir(parents=True, exist_ok=True) CHAT_DIR.mkdir(parents=True, exist_ok=True) @@ -1076,6 +1078,181 @@ class ChatApp(Gtk.Application): return False + # ------------------------- AI IMAGES -------------------------------- + + def save_base64_image(self, b64_data: str, ext: str = "png") -> str | None: + try: + raw = base64.b64decode(b64_data) + stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f") + out = GENERATED_DIR / f"gen-{stamp}.{ext}" + out.write_bytes(raw) + return str(out.resolve()) + except Exception as e: + print("save_base64_image error:", e) + return None + + + def download_image_to_cache(self, url: str) -> str | None: + try: + import urllib.request + + stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f") + + ext = "png" + low = url.lower() + if ".jpg" in low or ".jpeg" in low: + ext = "jpg" + elif ".webp" in low: + ext = "webp" + + out = GENERATED_DIR / f"gen-{stamp}.{ext}" + urllib.request.urlretrieve(url, str(out)) + return str(out.resolve()) + except Exception as e: + print("download_image_to_cache error:", e) + return None + + def finalize_ai_response(self, raw_text: str): + raw_text = (raw_text or "").strip() + + try: + data = json.loads(raw_text) + except Exception: + data = {"type": "text", "content": raw_text} + + try: + with open(self.current_chat, "r", encoding="utf-8") as f: + messages = json.load(f) or [] + except Exception: + messages = [] + + # son streaming bot placeholder'ını bul + bot_idx = None + for i in range(len(messages) - 1, -1, -1): + msg = messages[i] + if msg.get("role") == "bot" and msg.get("streaming"): + bot_idx = i + break + + if bot_idx is None: + messages.append({"role": "bot", "content": ""}) + bot_idx = len(messages) - 1 + + bot_msg = messages[bot_idx] + bot_msg.pop("streaming", None) + + result_type = str(data.get("type") or "text").strip().lower() + bot_msg["content"] = str(data.get("content") or "").strip() + + saved_images = [] + seen_saved = set() + + # usage varsa kaydet + usage = data.get("usage") + if isinstance(usage, dict): + bot_msg["usage"] = usage + else: + bot_msg.pop("usage", None) + + # 1) URL tabanlı görseller + image_candidates = [] + inline_data_urls = [] + + # tekil alanlar + for key in ("image", "url"): + val = data.get(key) + if isinstance(val, str) and val.strip(): + sval = val.strip() + if sval.startswith("data:image/"): + inline_data_urls.append(sval) + else: + image_candidates.append(sval) + + imgs = data.get("images") + if isinstance(imgs, list): + for item in imgs: + if isinstance(item, str) and item.strip(): + sval = item.strip() + if sval.startswith("data:image/"): + inline_data_urls.append(sval) + else: + image_candidates.append(sval) + + elif isinstance(item, dict): + u = item.get("url") + if isinstance(u, str) and u.strip(): + sval = u.strip() + if sval.startswith("data:image/"): + inline_data_urls.append(sval) + else: + image_candidates.append(sval) + + iu = item.get("image_url") + if isinstance(iu, str) and iu.strip(): + sval = iu.strip() + if sval.startswith("data:image/"): + inline_data_urls.append(sval) + else: + image_candidates.append(sval) + + elif isinstance(iu, dict): + uu = iu.get("url") + if isinstance(uu, str) and uu.strip(): + sval = uu.strip() + if sval.startswith("data:image/"): + inline_data_urls.append(sval) + else: + image_candidates.append(sval) + + for url in image_candidates: + local_path = self.download_image_to_cache(url) + if local_path and local_path not in seen_saved: + seen_saved.add(local_path) + saved_images.append(local_path) + + # 2) base64 tabanlı görseller + base64_candidates = [] + + for item in inline_data_urls: + if item.startswith("data:image/") and "," in item: + base64_candidates.append(item.split(",", 1)[1].strip()) + + one_b64 = data.get("image_base64") + if isinstance(one_b64, str) and one_b64.strip(): + base64_candidates.append(one_b64.strip()) + + many_b64 = data.get("images_base64") + if isinstance(many_b64, list): + for item in many_b64: + if isinstance(item, str) and item.strip(): + base64_candidates.append(item.strip()) + elif isinstance(item, dict): + b = item.get("b64_json") + if isinstance(b, str) and b.strip(): + base64_candidates.append(b.strip()) + + for item in base64_candidates: + if item.startswith("data:image/") and "," in item: + item = item.split(",", 1)[1].strip() + + local_path = self.save_base64_image(item, "png") + if local_path and local_path not in seen_saved: + seen_saved.add(local_path) + saved_images.append(local_path) + + if saved_images: + bot_msg["images"] = saved_images + else: + bot_msg.pop("images", None) + + messages[bot_idx] = bot_msg + + try: + with open(self.current_chat, "w", encoding="utf-8") as f: + json.dump(messages, f, ensure_ascii=False, indent=2) + except Exception as e: + print("finalize_ai_response write error:", e) + # ---------------- IMAGE,DOCS PREVIEW AND ATTACH MENU ---------------- def _measure_text_px(self, widget: Gtk.Widget, text: str) -> int: @@ -1534,23 +1711,42 @@ class ChatApp(Gtk.Application): self.typing_dots = 0 GLib.timeout_add(500, self.animate_typing) - def after_ai_response(self): + def hide_typing_indicator(self): self.typing_active = False self.stream_active = False - self.ai_proc = None - self.ai_stop_requested = False - self.set_generating_state(False) try: self.typing_row.set_visible(False) except Exception: pass - self.load_chat() - self.scroll_to_bottom() + def after_ai_response(self): + self.hide_typing_indicator() + self.set_generating_state(False) + + if getattr(self, "ai_stop_requested", False): + try: + with open(self.current_chat, "r", encoding="utf-8") as f: + messages = json.load(f) or [] + + messages.append({ + "role": "info", + "content": self("o_Cancelled") + }) + + with open(self.current_chat, "w", encoding="utf-8") as f: + json.dump(messages, f, ensure_ascii=False, indent=2) + except Exception: + pass + + self.ai_proc = None + self.ai_stop_requested = False self.streaming_row = None self.streaming_label = None + self.load_chat() + self.scroll_to_bottom(force=True) + # ---------------- MODELS LIST ---------------- def apply_models_visibility(self): @@ -2081,7 +2277,8 @@ class ChatApp(Gtk.Application): pass def open_language_dialog(self): - dialog = Gtk.Dialog(title=self("Dil"), transient_for=self.win) + dialog = Gtk.Dialog(transient_for=self.win) + self.bind_i18n(dialog, "title", "o_Language") dialog.set_modal(True) content = dialog.get_content_area() @@ -2094,7 +2291,8 @@ class ChatApp(Gtk.Application): current_lang = self.get_ui_language() langs = self.get_available_ui_languages() - label = Gtk.Label(label=self("Dil")) + label = Gtk.Label() + self.bind_i18n(label, "label", "o_Language") label.set_xalign(0) content.append(label) @@ -2199,7 +2397,7 @@ class ChatApp(Gtk.Application): is_dark = bool(cfg.get("dark_mode", True)) mode_name = "Dark Mode" if is_dark else "Light Mode" - dialog = Gtk.Dialog(title=f"Kişiselleştirme ({mode_name})", transient_for=self.win) + dialog = Gtk.Dialog(title=f"{self('o_Personalization')} ({mode_name})",transient_for=self.win) dialog.set_modal(True) dialog.set_default_size(460, 340) @@ -2271,18 +2469,17 @@ class ChatApp(Gtk.Application): rows[key] = entry - add_row("Genel arkaplan", "window_bg") - add_row("Input arkaplan", "input_bg") - add_row("Kullanıcı balon arkaplan", "user_bg") - add_row("Kullanıcı yazı rengi", "user_text") - add_row("Bot balon arkaplan", "bot_bg") - add_row("Bot yazı rengi", "bot_text") - add_row("Sidebar yazı rengi", "sidebar_text") + add_row(self("o_Window_BG"), "window_bg") + add_row(self("o_Input_BG"), "input_bg") + add_row(self("o_User_BG"), "user_bg") + add_row(self("o_User_Text"), "user_text") + add_row(self("o_Bot_BG"), "bot_bg") + add_row(self("o_Bot_Text"), "bot_text") + add_row(self("o_Sidebar_Text"), "sidebar_text") hint = Gtk.Label( label=( - f"Şu anda {mode_name} renklerini düzenliyorsun.\n" - "Hex formatı kullan: örn #303643" + f"{self('o_Color_Edit_Hint')}" ) ) hint.set_xalign(0) @@ -2290,7 +2487,7 @@ class ChatApp(Gtk.Application): hint.add_css_class("dim-label") content.append(hint) - bg_path_label = Gtk.Label(label="Chat arkaplan resmi") + bg_path_label = Gtk.Label(label=f"{self('o_Background_Hint')}") bg_path_label.set_xalign(0) content.append(bg_path_label) @@ -2298,11 +2495,11 @@ class ChatApp(Gtk.Application): bg_path_entry = Gtk.Entry() bg_path_entry.set_hexpand(True) - bg_path_entry.set_placeholder_text("/dosya-yolu/resim.png") + self.bind_i18n(bg_path_entry, "placeholder", "o_Background_Path_Placeholder") bg_path_entry.set_text(self._get_chat_bg_image_path()) - bg_pick_btn = Gtk.Button(label="Seç") - bg_clear_btn = Gtk.Button(label="Temizle") + bg_pick_btn = Gtk.Button(label=f"{self('o_Select_Image')}") + bg_clear_btn = Gtk.Button(label=f"{self('o_Clear')}") bg_pick_btn.connect("clicked", on_pick_bg) bg_clear_btn.connect("clicked", on_clear_bg) @@ -2312,7 +2509,7 @@ class ChatApp(Gtk.Application): content.append(bg_path_controls) - reset_btn = Gtk.Button(label="Varsayılana dön") + reset_btn = Gtk.Button(label=f"{self('o_Reset_Default')}") content.append(reset_btn) cancel_btn = Gtk.Button() @@ -2368,6 +2565,9 @@ class ChatApp(Gtk.Application): dialog.present() def open_style_dialog(self): + dialog = Gtk.Dialog(transient_for=self.win) + self.bind_i18n(dialog, "title", "o_Response_Style") + dialog = Gtk.Dialog(title="Cevap Tarzı", transient_for=self.win) dialog.set_modal(True) @@ -2375,7 +2575,7 @@ class ChatApp(Gtk.Application): content.set_spacing(8) entry = Gtk.Entry() - entry.set_placeholder_text("Örn: Samimi ve esprili konuş") + self.bind_i18n(entry, "placeholder", "o_Response_Style_Placeholder") cfg = self.load_config() entry.set_text(cfg.get("response_style", "") or "") @@ -2492,12 +2692,12 @@ class ChatApp(Gtk.Application): dialog = Gtk.Dialog() dialog.set_transient_for(self.win) dialog.set_modal(True) - dialog.set_title("OpenRouter API Key") + self.bind_i18n(dialog, "title", "o_OpenRouter_API_Key") content = dialog.get_content_area() entry = Gtk.Entry() - entry.set_placeholder_text("OpenRouter API Key girin...") + self.bind_i18n(entry, "placeholder", "o_OpenRouter_Placeholder") entry.set_visibility(False) content.append(entry) # Ctrl+V ile yapıştırmayı garanti et @@ -2823,6 +3023,9 @@ class ChatApp(Gtk.Application): if not (0 <= idx < len(messages)): return + if getattr(self, "is_generating", False): + return + role = (messages[idx].get("role") or "").strip() content = (messages[idx].get("content") or "").strip() if not content: @@ -2953,6 +3156,9 @@ class ChatApp(Gtk.Application): self.load_chat() self.show_typing_indicator() + self.ai_stop_requested = False + self.set_generating_state(True) + # 6) AI çağır: zincir referanslarla birlikte threading.Thread(target=self.call_ai, args=(selected_arg,), daemon=True).start() @@ -3287,7 +3493,11 @@ class ChatApp(Gtk.Application): c = int(u.get("completion_tokens", 0) or 0) t = int(u.get("total_tokens", 0) or 0) - usage_txt = f"Girdi token: {p} | Çıktı token: {c} | Toplam: {t}" + usage_txt = ( + f"{self('o_Input_Tokens')}: {p} | " + f"{self('o_Output_Tokens')}: {c} | " + f"{self('o_Total_Tokens')}: {t}" + ) usage_label = Gtk.Label(label=usage_txt) usage_label.set_xalign(0) @@ -3314,7 +3524,29 @@ class ChatApp(Gtk.Application): bubble.add_css_class("selected") # ---------------- APPLY BUTTONS ---------------- + gen_st = msg.get("gen_status") + if isinstance(gen_st, dict) and "status" in gen_st: + gen_status = gen_st.get("status") + gen_err = (gen_st.get("err") or "").strip() + + if gen_status == "cancel": + txt = f"⏭️ {self('o_Cancelled')}" + elif gen_status == "fail": + txt = f"❌ {self('o_Generation_Failed')}: {gen_err}" + else: + txt = "" + + if txt: + status_label = Gtk.Label(label=txt) + status_label.set_wrap(True) + status_label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) + status_label.set_xalign(0) + status_label.add_css_class("refs-preview-line") + bubble.append(status_label) + + if msg.get("role") != "user": + # daha önce sonuç yazıldıysa buton göstermeyelim, sadece feedback gösterelim st = msg.get("apply_status") if isinstance(st, dict) and "status" in st: @@ -3322,11 +3554,11 @@ class ChatApp(Gtk.Application): err = (st.get("err") or "").strip() if status == "ok": - txt = "✅ Dosya değiştirildi." + txt = f"✅ {self('o_File_Changed')}" elif status == "fail": txt = f"❌ APPLY FAILED: {err}" elif status == "cancel": - txt = "⏭️ İşlem iptal edildi." + txt = f"⏭️ {self('o_Cancelled')}" else: txt = "" @@ -3458,11 +3690,14 @@ class ChatApp(Gtk.Application): # AI düşünüyorsa typing satırını en alta geri ekle if getattr(self, "typing_active", False): - if kept_typing_row is not None: - self.chat_box.append(kept_typing_row) - else: - # typing_row yoksa güvenli fallback - self.show_typing_indicator() + try: + if self.typing_row.get_parent() is not self.content_box: + parent = self.typing_row.get_parent() + if parent is not None: + parent.remove(self.typing_row) + self.content_box.append(self.typing_row) + except Exception: + pass GLib.idle_add(self.scroll_to_bottom) @@ -3824,20 +4059,30 @@ class ChatApp(Gtk.Application): messages = json.load(f) or [] changed = False + if messages and isinstance(messages[-1], dict): last = messages[-1] + + # Son mesaj boş streaming bot placeholder ise sil if last.get("role") in ("bot", "assistant") and last.get("streaming"): content = (last.get("content") or "").strip() + if not content: messages.pop() changed = True - else: - last.pop("streaming", None) - changed = True + + # Cancel bilgisini son user mesajına yaz + for j in range(len(messages) - 1, -1, -1): + msg = messages[j] + if msg.get("role") == "user": + msg["gen_status"] = {"status": "cancel", "err": ""} + changed = True + break if changed: with open(self.current_chat, "w", encoding="utf-8") as f: json.dump(messages, f, ensure_ascii=False, indent=2) + except Exception: pass @@ -4260,6 +4505,8 @@ class ChatApp(Gtk.Application): partial = "" last_len = 0 last_push = 0 + stderr_chunks = [] + json_mode = False out_fd = proc.stdout.fileno() err_fd = proc.stderr.fileno() @@ -4274,18 +4521,28 @@ class ChatApp(Gtk.Application): text = decoder.decode(data) if text: partial += text + + # Gelen çıktı JSON gibi mi? + stripped = partial.lstrip() + if stripped.startswith("{") or stripped.startswith("["): + json_mode = True + # daha sık güncelle (stream hissi) now = GLib.get_monotonic_time() # mikro-saniye if (now - last_push) > 8_000: # 8ms last_push = now new_part = partial[last_len:] last_len = len(partial) - if new_part: + + # Sadece düz metinse ekrana akıt + if new_part and not json_mode: GLib.idle_add(self.update_stream_text, new_part) + if err_fd in rlist: - # stderr'i biriktirmek istersen burada alabilirsin (şimdilik sadece drain) - _ = os.read(err_fd, 4096) + err_data = os.read(err_fd, 4096) + if err_data: + stderr_chunks.append(err_data.decode("utf-8", errors="replace")) if proc.poll() is not None: break @@ -4297,25 +4554,24 @@ class ChatApp(Gtk.Application): new_part = partial[last_len:] last_len = len(partial) - if new_part: + if new_part and not json_mode: GLib.idle_add(self.update_stream_text, new_part) + proc.wait() rc = proc.returncode self.ai_proc = None + stderr_text = "".join(stderr_chunks).strip() + if self.ai_stop_requested: GLib.idle_add(self.after_ai_response) return if rc != 0: - err = "" - try: - err_bytes = proc.stderr.read() if proc.stderr else b"" - err = err_bytes.decode("utf-8", errors="replace").strip() if err_bytes else "Bilinmeyen hata" - except Exception: - err = "Bilinmeyen hata" + err = stderr_text or partial.strip() or "Bilinmeyen hata" GLib.idle_add(self.handle_ai_error, err) else: + GLib.idle_add(self.finalize_ai_response, partial) GLib.idle_add(self.after_ai_response) except Exception as e: