#!/usr/bin/env python3 import gi import gc import os import sys import json import subprocess import threading import codecs from pathlib import Path import base64 import tempfile from datetime import datetime import signal import shutil gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Gtk, GLib, Gio, Gdk, Pango, Adw, GdkPixbuf Adw.init() CONFIG_PATH = Path.home() / ".config" / "capture-ai" / "config.json" CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) BASE_DIR = Path.home() / ".cache" / "capture-ai" CHAT_DIR = BASE_DIR / "chats" LANG_DIR = Path.home() / "capture-ai" / "language" AI_SCRIPT = str(Path.home() / "capture-ai/ai.py") GENERATED_DIR = BASE_DIR / "generated_images" GENERATED_DIR.mkdir(parents=True, exist_ok=True) GENERATED_FILES_DIR = BASE_DIR / "generated_files" GENERATED_FILES_DIR.mkdir(parents=True, exist_ok=True) CHAT_DIR.mkdir(parents=True, exist_ok=True) DEFAULT_CUSTOM_COLORS_DARK = { "window_bg": "#111111", "input_bg": "#1e1e1e", "user_bg": "#303643", "user_text": "#ffffff", "bot_bg": "#222222", "bot_text": "#ffffff", "sidebar_text": "#dddddd", "local_model_highlight": "#ff9800", } DEFAULT_CUSTOM_COLORS_LIGHT = { "window_bg": "#f5f5f5", "input_bg": "#ffffff", "user_bg": "#dbe7ff", "user_text": "#111111", "bot_bg": "#ececec", "bot_text": "#111111", "sidebar_text": "#111111", "local_model_highlight": "#ff9800", } def ensure_bool_strict(value, default): if isinstance(value, bool): return value if isinstance(value, str): v = value.strip().lower() if v in ("true", "1", "yes", "on"): return True if v in ("false", "0", "no", "off"): return False return default def ensure_base_config(config: dict) -> dict: defaults = { "ui_language": "en", "last_chat": "default.json", "dark_mode": True, "open_router_key": "", "tavily_api_key": "", "ask_for_web_search": True, "prompt_chooser_blocks": ["copyable"], "response_style": "normal", "show_usage": True, "show_token_value": False, "token_value": "2.0", "force_ui_language": False, "is_mic_online": True, "use_desktop_voice": False, "stt_model_online": "openai/gpt-audio-mini", "whisper_cpp_bin": "", "whisper_cpp_model": "", "pinned_chats": [], "custom_colors_dark": dict(DEFAULT_CUSTOM_COLORS_DARK), "custom_colors_light": dict(DEFAULT_CUSTOM_COLORS_LIGHT), "chat_bg_image": "", "rag_settings": { "recent_message_count": 10, "retrieved_chunk_count": 5, "summary_update_every": 20, "memory_chunk_max_chars": 1200, "summary_max_chars": 6000, "code_context_max_chars": 8000, "use_summary": True, "use_recent_messages": True, "use_retrieval": True, "use_code_context": True, "include_recent_attachments": False }, "chat_rag_switch": { "default.json": True }, "ai_models": [{ "id": "google/gemini-2.5-flash-image", "local": False }], "chat_models": { "default.json": { "id": "google/gemini-2.5-flash-image", "local": False } }, "local_providers": { "ollama": { "enabled": False, "base_url": "http://127.0.0.1:11434", "run_startup": "ollama serve", "stop_command": "pkill -f 'ollama serve'", "system_error": "Ollama bağlantı hatası.\nBeklenen adres: {base_url}\n'ollama serve' çalıştır.", "temperature": "", "top_p": "", "top_k": "", "repeat_penalty": "", "num_ctx": "", "num_predict": "", "keep_alive": "", "system_prompt": "" } }, } bool_keys = [ "dark_mode", "ask_for_web_search", "show_usage", "show_token_value", "force_ui_language", "is_mic_online", "use_desktop_voice", ] changed = False for key, default in defaults.items(): if key not in config: config[key] = default changed = True continue value = config.get(key) if key in bool_keys: fixed = ensure_bool_strict(value, default) if fixed != value: config[key] = fixed changed = True continue if isinstance(default, str): if not isinstance(value, str) or not value.strip(): config[key] = default changed = True continue if isinstance(default, list): if not isinstance(value, list): config[key] = default changed = True continue if isinstance(default, dict): if not isinstance(value, dict): config[key] = default changed = True continue # ai_models normalize if isinstance(config["ai_models"], list): cleaned_models = [] for item in config["ai_models"]: if isinstance(item, dict): mid = str(item.get("id") or "").strip() if not mid: continue cleaned_models.append({ "id": mid, "local": ensure_bool_strict(item.get("local"), False) }) elif isinstance(item, str): mid = item.strip() if not mid: continue cleaned_models.append({ "id": mid, "local": False }) if not cleaned_models: cleaned_models = [dict(defaults["ai_models"][0])] if cleaned_models != config["ai_models"]: config["ai_models"] = cleaned_models changed = True # chat_models normalize if isinstance(config["chat_models"], dict): fixed_chat_models = {} for chat_name, model in config["chat_models"].items(): chat_key = str(chat_name or "").strip() if not chat_key: continue if isinstance(model, dict): mid = str(model.get("id") or "").strip() if not mid: mid = defaults["chat_models"]["default.json"]["id"] fixed_chat_models[chat_key] = { "id": mid, "local": ensure_bool_strict(model.get("local"), False) } elif isinstance(model, str): mid = model.strip() if not mid: mid = defaults["chat_models"]["default.json"]["id"] fixed_chat_models[chat_key] = { "id": mid, "local": False } if not fixed_chat_models: fixed_chat_models = dict(defaults["chat_models"]) if fixed_chat_models != config["chat_models"]: config["chat_models"] = fixed_chat_models changed = True # rag_settings normalize rag_defaults = defaults["rag_settings"] if not isinstance(config.get("rag_settings"), dict): config["rag_settings"] = dict(rag_defaults) changed = True else: fixed_rag = dict(rag_defaults) raw_rag = config.get("rag_settings", {}) int_keys = [ "recent_message_count", "retrieved_chunk_count", "summary_update_every", "memory_chunk_max_chars", "summary_max_chars", "code_context_max_chars", ] bool_rag_keys = [ "use_summary", "use_recent_messages", "use_retrieval", "use_code_context", "include_recent_attachments", ] for key in int_keys: try: val = int(raw_rag.get(key, rag_defaults[key])) except Exception: val = rag_defaults[key] if key in ("recent_message_count", "retrieved_chunk_count"): val = max(0, min(val, 50)) elif key == "summary_update_every": val = max(1, min(val, 200)) else: val = max(100, min(val, 100_000)) fixed_rag[key] = val for key in bool_rag_keys: fixed_rag[key] = ensure_bool_strict( raw_rag.get(key, rag_defaults[key]), rag_defaults[key] ) if fixed_rag != config.get("rag_settings"): config["rag_settings"] = fixed_rag changed = True # local_providers normalize provider_defaults = { "enabled": False, "base_url": "http://127.0.0.1:11434", "run_startup": "", "stop_command": "", "system_error": "", "temperature": "", "top_p": "", "top_k": "", "repeat_penalty": "", "num_ctx": "", "num_predict": "", "keep_alive": "", "system_prompt": "" } if not isinstance(config.get("local_providers"), dict): config["local_providers"] = { "ollama": dict(provider_defaults) } changed = True else: fixed_local_providers = {} for provider_name, provider_cfg in config["local_providers"].items(): pname = str(provider_name or "").strip() if not pname: continue if not isinstance(provider_cfg, dict): provider_cfg = {} fixed = dict(provider_defaults) fixed["enabled"] = ensure_bool_strict( provider_cfg.get("enabled"), provider_defaults["enabled"] ) for key in [ "base_url", "run_startup", "stop_command", "system_error", "temperature", "top_p", "top_k", "repeat_penalty", "num_ctx", "num_predict", "keep_alive", "system_prompt", ]: val = provider_cfg.get(key, provider_defaults[key]) if not isinstance(val, str): val = str(provider_defaults[key]) if key == "base_url" and not val.strip(): val = provider_defaults["base_url"] fixed[key] = val fixed_local_providers[pname] = fixed if not fixed_local_providers: fixed_local_providers = { "ollama": dict(provider_defaults) } if fixed_local_providers != config.get("local_providers"): config["local_providers"] = fixed_local_providers changed = True return config # Config dosyasından son açılan chat'i al, yoksa default.json kullan if CONFIG_PATH.exists(): try: with open(CONFIG_PATH, "r", encoding="utf-8") as f: config = json.load(f) or {} except json.JSONDecodeError: try: bad_path = CONFIG_PATH.with_suffix(".broken.json") shutil.copy2(CONFIG_PATH, bad_path) except Exception: pass config = {} old_config = json.loads(json.dumps(config)) config = ensure_base_config(config) if config != old_config: with open(CONFIG_PATH, "w", encoding="utf-8") as f: json.dump(config, f, indent=2, ensure_ascii=False) else: config = ensure_base_config({}) with open(CONFIG_PATH, "w", encoding="utf-8") as f: json.dump(config, f, indent=2, ensure_ascii=False) last_chat_name = config["last_chat"] dark_mode = config["dark_mode"] DEFAULT_CHAT = CHAT_DIR / last_chat_name if not DEFAULT_CHAT.exists(): DEFAULT_CHAT.write_text( json.dumps({ "summary": "", "messages": [], "code_context": {}, "memory_chunks": [] }, ensure_ascii=False, indent=2), encoding="utf-8" ) def fix_mojibake(s: str) -> str: if not s: return s # Mojibake tipik işaretleri if ("Ã" not in s) and ("Å" not in s) and ("â" not in s): return s try: return s.encode("latin-1").decode("utf-8") except Exception: return s class ChatApp(Gtk.Application): def __init__(self, image_path=None): super().__init__() self.connect("activate", self.on_activate) self.loaded_message_start = None self.lazy_chat_initial_count = 10 self.lazy_chat_batch_count = 30 self._loading_older_messages = False self.current_chat = DEFAULT_CHAT self.image_path = image_path self.pending_images = [] self.pending_files = [] self._lang_cache = None self._lang_cache_lang = None if image_path: try: from urllib.parse import urlparse, unquote p = str(image_path).strip() # file://... uri gelirse path’e çevir if p.startswith("file://"): p = unquote(urlparse(p).path) rp = str(Path(p).expanduser().resolve()) if Path(rp).exists(): self.pending_images = [rp] except Exception: pass self.sidebar_expanded = True self.chats_list_open = True self.models_list_open = True self.ai_proc = None self.ai_stop_requested = False self.is_generating = False self.ai_procs = {} self.ai_stop_requested_chats = set() self.generating_chats = set() self.stream_ui_ready_chats = set() self.stream_active_chats = set() self.auto_focus_enabled = True self.auto_scroll_enabled = True self.selected_indexes = set() self.typing_active = False self.selection_mode = False # aktif chat’in modeli (chat başına) self.active_model = None self.streaming_row = None # ----------------- OPTIMIZATION ----------------- def clear_closed_chat_memory(self): try: self.streaming_row = None self.streaming_label = None self._streaming_bot_idx = None try: self.typing_row.set_visible(False) except Exception: pass while True: child = self.chat_box.get_first_child() if not child: break self.chat_box.remove(child) try: self.selection_label.set_text("") self.selection_row.set_opacity(0.0) self.selection_row.set_sensitive(False) except Exception: pass try: self.pending_images.clear() self.pending_files.clear() except Exception: pass try: while True: c = self.preview_hbox.get_first_child() if not c: break self.preview_hbox.remove(c) self.preview_row.set_visible(False) except Exception: pass try: buf = self.textview.get_buffer() buf.set_text("") except Exception: pass gc.collect() return False except Exception: return False # ---------------- CONFIG HELPERS ---------------- def load_config(self): try: if CONFIG_PATH.exists(): with open(CONFIG_PATH, "r", encoding="utf-8") as f: return json.load(f) or {} except Exception as e: print("config read error:", e) return {} def save_config(self, cfg: dict): try: CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) with open(CONFIG_PATH, "w", encoding="utf-8") as f: json.dump(cfg, f, ensure_ascii=False, indent=2) except Exception as e: print("config save error:", e) def load_chat_data_ui(self, path=None): path = Path(path or self.current_chat) try: raw = json.loads(path.read_text(encoding="utf-8")) or [] except Exception: raw = [] # Eski format: direkt liste if isinstance(raw, list): return { "summary": "", "messages": raw, "code_context": {}, "memory_chunks": [] } # Yeni format: dict if not isinstance(raw, dict): raw = {} raw.setdefault("summary", "") raw.setdefault("messages", []) raw.setdefault("code_context", {}) raw.setdefault("memory_chunks", []) if not isinstance(raw["messages"], list): raw["messages"] = [] return raw def save_chat_data_ui(self, data, path=None): path = Path(path or self.current_chat) if not isinstance(data, dict): data = { "summary": "", "messages": [], "code_context": {}, "memory_chunks": [] } data.setdefault("summary", "") data.setdefault("messages", []) data.setdefault("code_context", {}) data.setdefault("memory_chunks", []) path.write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" ) def get_context_mode(self, chat_path=None) -> str: chat_path = Path(chat_path or self.current_chat) cfg = ensure_base_config(self.load_config()) chat_rag_switch = cfg.setdefault("chat_rag_switch", {}) direct_mode = ensure_bool_strict( chat_rag_switch.get(chat_path.name, True), True ) return "direct" if direct_mode else "rag" def set_context_mode(self, mode: str, chat_path=None): mode = str(mode or "").strip().lower() if mode not in ("rag", "direct"): mode = "direct" chat_path = Path(chat_path or self.current_chat) cfg = ensure_base_config(self.load_config()) cfg.setdefault("chat_rag_switch", {}) cfg["chat_rag_switch"][chat_path.name] = (mode == "direct") self.save_config(cfg) self.refresh_context_mode_switch() def get_ui_strings_map(self) -> dict: current_lang = self.get_ui_language() if ( self._lang_cache is not None and self._lang_cache_lang == current_lang ): return self._lang_cache lang_path = LANG_DIR / f"{current_lang}.json" fallback_path = LANG_DIR / "en.json" data = {} try: if lang_path.exists(): raw = json.loads(lang_path.read_text(encoding="utf-8")) or {} if isinstance(raw, dict): data = raw except Exception as e: print(f"language file read error ({lang_path.name}):", e) if not data: try: if fallback_path.exists(): raw = json.loads(fallback_path.read_text(encoding="utf-8")) or {} if isinstance(raw, dict): data = raw except Exception as e: print(f"fallback language file read error ({fallback_path.name}):", e) if not isinstance(data, dict): data = {} self._lang_cache = data self._lang_cache_lang = current_lang return self._lang_cache def get_ui_lang_map(self, lang: str | None = None) -> dict: if lang is None or lang == self.get_ui_language(): return self.get_ui_strings_map() # farklı bir dil özellikle istenirse bir kerelik oku lang = str(lang or "").strip().lower() or "en" lang_path = LANG_DIR / f"{lang}.json" fallback_path = LANG_DIR / "en.json" data = {} try: if lang_path.exists(): raw = json.loads(lang_path.read_text(encoding="utf-8")) or {} if isinstance(raw, dict): data = raw except Exception as e: print(f"language file read error ({lang_path.name}):", e) if not data: try: if fallback_path.exists(): raw = json.loads(fallback_path.read_text(encoding="utf-8")) or {} if isinstance(raw, dict): data = raw except Exception as e: print(f"fallback language file read error ({fallback_path.name}):", e) return data if isinstance(data, dict) else {} def __call__(self, key: str, **kwargs) -> str: lang_map = self.get_ui_lang_map() text = str(lang_map.get(key, key)) try: return text.format(**kwargs) except Exception: return text def render_message_text(self, msg: dict) -> str: if not isinstance(msg, dict): return "" if msg.get("error_key"): params = msg.get("error_params") or {} if not isinstance(params, dict): params = {} error_key = str(msg.get("error_key")) # Zaten genel error wrapper ise tekrar "Error:" ekleme if error_key == "o_Error_With_Detail": return self(error_key, **params) inner = self(error_key, **params) return self("o_Error_With_Detail", error=inner) if msg.get("status_key"): params = msg.get("status_params") or {} if not isinstance(params, dict): params = {} return self(str(msg.get("status_key")), **params) if msg.get("i18n_key"): params = msg.get("i18n_params") or {} if not isinstance(params, dict): params = {} return self(str(msg.get("i18n_key")), **params) return str(msg.get("content") or "") def append_text_with_links(self, parent, text: str): import re text = str(text or "") url_re = re.compile(r"(https?://[^\s\]\)\"'>]+)") parts = url_re.split(text) for part in parts: if not part: continue if url_re.fullmatch(part): clean_url = part.rstrip(".,;:") trailing = part[len(clean_url):] link = Gtk.LinkButton.new_with_label(clean_url, clean_url) link.set_halign(Gtk.Align.START) link.set_uri(clean_url) self._consume_click(link) parent.append(link) if trailing: lab = Gtk.Label(label=trailing) lab.set_xalign(0) parent.append(lab) else: lab = Gtk.Label(label=part) lab.set_xalign(0) lab.set_wrap(True) lab.set_wrap_mode(Pango.WrapMode.WORD_CHAR) lab.set_halign(Gtk.Align.START) parent.append(lab) def get_ui_language(self) -> str: cfg = ensure_base_config(self.load_config()) lang = str(cfg["ui_language"]).strip().lower() return lang if lang else "en" def set_ui_language(self, lang: str): lang = str(lang or "").strip().lower() if not lang: return cfg = self.load_config() cfg["ui_language"] = lang self.save_config(cfg) self._lang_cache = None self._lang_cache_lang = None def get_available_ui_languages(self) -> list[str]: out = [] try: if LANG_DIR.exists(): for p in LANG_DIR.glob("*.json"): name = p.stem.strip().lower() if name: out.append(name) except Exception as e: print("language directory read error:", e) return sorted(set(out)) def _get_active_colors_key(self) -> str: cfg = ensure_base_config(self.load_config()) is_dark = cfg["dark_mode"] return "custom_colors_dark" if is_dark else "custom_colors_light" def _get_default_theme_colors(self, is_dark: bool | None = None) -> dict: if is_dark is None: cfg = ensure_base_config(self.load_config()) is_dark = cfg["dark_mode"] base = DEFAULT_CUSTOM_COLORS_DARK if is_dark else DEFAULT_CUSTOM_COLORS_LIGHT return dict(base) def _get_theme_colors(self) -> dict: cfg = ensure_base_config(self.load_config()) is_dark = cfg["dark_mode"] key = "custom_colors_dark" if is_dark else "custom_colors_light" raw = cfg[key] defaults = self._get_default_theme_colors(is_dark) out = {} for k, v in defaults.items(): out[k] = str(raw.get(k, v)) return out def _save_theme_colors(self, colors: dict): cfg = self.load_config() key = self._get_active_colors_key() cfg[key] = dict(colors) self.save_config(cfg) def _reset_active_theme_colors_to_default(self): cfg = ensure_base_config(self.load_config()) is_dark = cfg["dark_mode"] key = "custom_colors_dark" if is_dark else "custom_colors_light" cfg[key] = self._get_default_theme_colors(is_dark) self.save_config(cfg) def _normalize_model_entry(self, item) -> dict | None: if isinstance(item, str): mid = item.strip() if not mid: return None return { "id": mid, "local": False } if not isinstance(item, dict): return None mid = str(item.get("id", "")).strip() if not mid: return None return { "id": mid, "local": bool(item.get("local", False)) } def _get_models_list(self, cfg: dict) -> list[dict]: raw = cfg.get("ai_models", []) if not isinstance(raw, list): raw = [] out = [] seen = set() for item in raw: norm = self._normalize_model_entry(item) if not norm: continue mid = norm["id"] if mid in seen: continue seen.add(mid) out.append(norm) return out def _get_model_ids(self, cfg: dict) -> list[str]: return [m["id"] for m in self._get_models_list(cfg)] def _find_model_entry(self, cfg: dict, model_id: str) -> dict | None: model_id = str(model_id or "").strip() if not model_id: return None for item in self._get_models_list(cfg): if item["id"] == model_id: return item return None def _ensure_min_one_model(self, cfg: dict): models = self._get_models_list(cfg) if not models: models = [{ "id": "google/gemini-2.5-flash-image", "local": False }] cfg["ai_models"] = models self.save_config(cfg) return models def get_default_model(self, cfg: dict) -> str: models = self._ensure_min_one_model(cfg) return str(models[0]["id"]) def get_chat_model(self, chat_path: Path) -> str: cfg = ensure_base_config(self.load_config()) models = self._ensure_min_one_model(cfg) model_ids = [m["id"] for m in models] chat_models = cfg["chat_models"] key = chat_path.name selected = chat_models.get(key) model_id = "" if isinstance(selected, dict): model_id = str(selected.get("id") or "").strip() elif isinstance(selected, str): model_id = selected.strip() if (not model_id) or (model_id not in model_ids): model_id = str(models[0]["id"]) chat_models[key] = { "id": model_id, "local": bool(models[0].get("local", False)) } cfg["chat_models"] = chat_models self.save_config(cfg) return model_id def set_chat_model(self, chat_path: Path, model_id: str, is_local: bool | None = None): model_id = str(model_id).strip() if not model_id: return cfg = ensure_base_config(self.load_config()) cfg.setdefault("chat_models", {}) models = self._ensure_min_one_model(cfg) found = None rest = [] for item in models: if item["id"] == model_id and found is None: # mevcut model → local bilgisini koru current_local = bool(item.get("local", False)) found = { "id": model_id, "local": current_local if is_local is None else bool(is_local) } else: rest.append(item) # model hiç yoksa → yeni ekle if found is None: found = { "id": model_id, "local": bool(is_local) if is_local is not None else False } # LRU: en üste al cfg["ai_models"] = [found] + rest # chat mapping cfg["chat_models"][chat_path.name] = { "id": found["id"], "local": bool(found.get("local", False)) } self.save_config(cfg) # UI refresh self.active_model = { "id": found["id"], "local": bool(found.get("local", False)) } self.load_models_list() self.apply_models_visibility() # ---------------- UI BUILD ---------------- def on_activate(self, app): self.win = Gtk.ApplicationWindow(application=app) self.win.set_title("Capture AI") self.win.set_default_size(1100, 750) self.apply_theme(dark_mode) self.apply_css() root = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) root.add_css_class("app-root") self.win.set_child(root) self.setup_file_drop_target(root) # SIDEBAR self.sidebar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) self.sidebar.add_css_class("sidebar") self.sidebar.set_size_request(250, -1) self.sidebar.set_hexpand(False) self.sidebar.set_vexpand(True) self.sidebar.set_valign(Gtk.Align.FILL) root.append(self.sidebar) self.toggle_btn = Gtk.Button(label="☰") self.bind_i18n(self.toggle_btn, "tooltip", "o_Toggle_Sidebar") self.toggle_btn.connect("clicked", self.toggle_sidebar) self.sidebar.append(self.toggle_btn) self.new_btn_sidebar = Gtk.Button(label="+") self.new_btn_sidebar.set_visible(False) self.bind_i18n(self.new_btn_sidebar, "tooltip", "o_New_Chat") self.new_btn_sidebar.connect("clicked", self.create_chat) self.sidebar.append(self.new_btn_sidebar) # SIDEBAR SCROLL self.sidebar_scroll = Gtk.ScrolledWindow() self.sidebar_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) self.sidebar_scroll.set_hexpand(True) self.sidebar_scroll.set_vexpand(True) self.sidebar_scroll.set_valign(Gtk.Align.FILL) self.sidebar.append(self.sidebar_scroll) self.sidebar_scroll_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) self.sidebar_scroll_content.set_hexpand(True) self.sidebar_scroll_content.set_vexpand(False) self.sidebar_scroll.set_child(self.sidebar_scroll_content) self.sidebar_scroll_content.set_valign(Gtk.Align.START) # ---------------- CONTEXT MODE SWITCH ---------------- self.context_mode_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) self.context_mode_row.set_halign(Gtk.Align.CENTER) self.context_mode_row.set_hexpand(True) self.context_mode_row.set_margin_top(4) self.context_mode_row.set_margin_bottom(4) self.context_rag_label = Gtk.Label() self.bind_i18n(self.context_rag_label, "label", "o_Context_RAG") self.context_rag_label.set_xalign(0) self.context_rag_label.set_halign(Gtk.Align.START) self.context_mode_switch = Gtk.Switch() self.context_mode_switch.set_halign(Gtk.Align.CENTER) self.context_mode_switch.set_valign(Gtk.Align.CENTER) self.context_direct_label = Gtk.Label() self.bind_i18n(self.context_direct_label, "label", "o_Context_Direct") self.context_direct_label.set_xalign(0) self.context_direct_label.set_halign(Gtk.Align.START) self.context_mode_row.append(self.context_rag_label) self.context_mode_row.append(self.context_mode_switch) self.context_mode_row.append(self.context_direct_label) self.bind_i18n(self.context_mode_row, "tooltip", "o_Context_Mode") self.context_mode_switch.connect("notify::active", self.on_context_mode_switch_changed) self.sidebar_scroll_content.append(self.context_mode_row) self.refresh_context_mode_switch() # AI MODELS HEADER + LIST self.models_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) self.models_header.set_hexpand(True) self.models_toggle_btn = Gtk.Button(label=f"{self('o_AI_Models')} ▾") self.models_toggle_btn.connect("clicked", self.toggle_models_list) self.models_header.append(self.models_toggle_btn) models_spacer = Gtk.Box() self.models_header.append(models_spacer) self.add_model_btn = Gtk.Button(label="+") self.bind_i18n(self.add_model_btn, "tooltip", "o_Add_Model") self.add_model_btn.connect("clicked", self.open_add_model_dialog) self.models_header.append(self.add_model_btn) self.sidebar_scroll_content.append(self.models_header) self.models_list = Gtk.ListBox() self.models_list.connect("row-activated", self.on_model_row_activated) self.models_list.set_activate_on_single_click(False) self.sidebar_scroll_content.append(self.models_list) # CHATS HEADER self.chats_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) self.chats_toggle_btn = Gtk.Button(label=f"{self('o_Chats')} ▾") self.chats_toggle_btn.connect("clicked", self.toggle_chats_list) self.chats_header.append(self.chats_toggle_btn) self.new_btn = Gtk.Button(label="+") self.bind_i18n(self.new_btn, "tooltip", "o_New_Chat") self.new_btn.connect("clicked", self.create_chat) self.chats_header.append(self.new_btn) self.sidebar_scroll_content.append(self.chats_header) self.chat_list = Gtk.ListBox() self.chat_list.set_activate_on_single_click(True) self.chat_list.connect("row-activated", self.switch_chat) self.sidebar_scroll_content.append(self.chat_list) # SPACER self.sidebar_spacer = Gtk.Box() self.sidebar_spacer.set_vexpand(True) self.sidebar_spacer.set_visible(False) self.sidebar.append(self.sidebar_spacer) # SIDEBAR BOTTOM self.sidebar_bottom = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) self.sidebar_bottom.set_hexpand(True) self.sidebar_bottom.set_vexpand(False) self.sidebar_bottom.set_valign(Gtk.Align.END) self.sidebar.append(self.sidebar_bottom) # Dark mode dark_mode_btn = Gtk.Button(label="🌓") self.bind_i18n(dark_mode_btn, "tooltip", "o_Toggle_Dark_Mode") dark_mode_btn.connect("clicked", self.toggle_theme) self.sidebar_bottom.append(dark_mode_btn) # Settings self.settings_btn = Gtk.Button(label="⚙") self.bind_i18n(self.settings_btn, "tooltip", "o_Settings") self.settings_btn.connect("clicked", self.open_settings_menu) self.sidebar_bottom.append(self.settings_btn) # MAIN main = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) main.set_hexpand(True) main.set_vexpand(True) main.set_halign(Gtk.Align.FILL) root.append(main) # OVERLAY overlay = Gtk.Overlay() overlay.set_hexpand(True) overlay.set_vexpand(True) main.append(overlay) self.setup_file_drop_target(overlay) # Scroll content self.content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) self.content_box.set_hexpand(True) self.content_box.set_vexpand(True) self.content_box.add_css_class("chat-area") self.chat_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) self.chat_box.set_hexpand(True) self.content_box.append(self.chat_box) # Selection row: "X mesaj referans seçildi" + [X] self.selection_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) self.selection_row.set_margin_start(10) self.selection_row.set_halign(Gtk.Align.START) # --- TYPING INDICATOR (chat_box dışında, load_chat silmesin) --- self.typing_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) self.typing_row.set_halign(Gtk.Align.START) typing_bubble = Gtk.Box() typing_bubble.add_css_class("bot-bubble") self.typing_label = Gtk.Label() self.bind_i18n(self.typing_label, "label", "o_Thinking") self.typing_label.set_wrap(True) self.typing_label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) self.typing_label.set_xalign(0) self.typing_label.set_max_width_chars(60) typing_bubble.append(self.typing_label) self.typing_row.append(typing_bubble) # Başta gizli ama yerini korusun istersen: self.typing_row.set_visible(False) # KRİTİK: chat_box'a değil, content_box'a ekle self.content_box.append(self.typing_row) self.typing_dots = 0 self.typing_active = False self.selection_label = Gtk.Label(label="") self.selection_label.set_xalign(0) self.selection_label.set_halign(Gtk.Align.START) self.selection_label.set_hexpand(False) self.selection_clear_btn = Gtk.Button(label="✕") self.selection_clear_btn.add_css_class("flat") self.bind_i18n(self.selection_clear_btn, "tooltip", "o_Clear_Selection") self.selection_clear_btn.connect("clicked", self.clear_selection) # X'e basınca bubble click falan tetiklenmesin (sende _consume_click varsa) self._consume_click(self.selection_clear_btn) # Başta görünmez ama yer kaplasın (chat zıplamasın) self.selection_row.set_opacity(0.0) self.selection_row.set_sensitive(False) self.selection_row.set_size_request(-1, 34) # istersen 26-40 arası ayarla self.selection_row.append(self.selection_label) self.selection_row.append(self.selection_clear_btn) # ÖNEMLİ: tanımladıktan sonra append self.content_box.append(self.selection_row) # --- ATTACHMENTS PREVIEW STRIP (horizontal + scroll) --- self.preview_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) self.preview_row.set_hexpand(True) self.preview_row.set_halign(Gtk.Align.FILL) self.preview_row.set_margin_start(10) self.preview_prev_btn = Gtk.Button(label="<") self.preview_prev_btn.set_valign(Gtk.Align.CENTER) self.preview_prev_btn.set_vexpand(False) self.preview_prev_btn.set_size_request(28, 32) self.preview_prev_btn.add_css_class("flat") self.bind_i18n(self.preview_prev_btn, "tooltip", "o_Scroll_Left") self.preview_prev_btn.connect("clicked", self.scroll_preview_left) self.preview_prev_btn.set_visible(False) self.preview_row.append(self.preview_prev_btn) self.preview_scroller = Gtk.ScrolledWindow() self.preview_scroller.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER) self.preview_scroller.set_hexpand(True) self.preview_scroller.set_propagate_natural_height(False) self.preview_scroller.set_max_content_height(90) self.preview_scroller.set_min_content_height(90) self.preview_scroller.set_vexpand(False) self.preview_scroller.set_valign(Gtk.Align.END) hadj = self.preview_scroller.get_hadjustment() if hadj: hadj.connect("value-changed", lambda *_: GLib.idle_add(self._update_preview_overflow)) self.preview_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) self.preview_hbox.set_halign(Gtk.Align.START) self.preview_hbox.set_hexpand(False) self.preview_scroller.set_child(self.preview_hbox) self.preview_hbox.set_homogeneous(False) self.preview_row.append(self.preview_scroller) self.preview_next_btn = Gtk.Button(label=">") self.preview_next_btn.set_valign(Gtk.Align.CENTER) self.preview_next_btn.set_vexpand(False) self.preview_next_btn.set_size_request(28, 32) self.preview_next_btn.add_css_class("flat") self.bind_i18n(self.preview_next_btn, "tooltip", "o_Scroll_Right") self.preview_next_btn.connect("clicked", self.scroll_preview_right) self.preview_next_btn.set_visible(False) self.preview_row.append(self.preview_next_btn) self.preview_row.set_visible(False) # başta gizli (chat zıplamasın istersen opacity yaklaşımı da kullanabilirsin) self.preview_row.set_visible(False) self.bottom_spacer = Gtk.Box() self.bottom_spacer.set_size_request(-1, 80) self.content_box.append(self.bottom_spacer) self.scroll = Gtk.ScrolledWindow() self.scroll.set_hexpand(True) self.scroll.set_vexpand(True) self.scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) self.scroll.set_child(self.content_box) overlay.set_child(self.scroll) vadj = self.scroll.get_vadjustment() vadj.connect("value-changed", self.on_scroll_changed) # ---------------- BOTTOM BAR (PREVIEW + INPUT) ---------------- bottom_bar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) bottom_bar.set_hexpand(True) bottom_bar.set_halign(Gtk.Align.FILL) bottom_bar.set_vexpand(False) bottom_bar.set_margin_bottom(12) bottom_bar.set_valign(Gtk.Align.END) bottom_bar.set_vexpand(False) overlay.add_overlay(bottom_bar) self.setup_file_drop_target(bottom_bar) bottom_clamp = Adw.Clamp() bottom_clamp.set_hexpand(True) bottom_clamp.set_halign(Gtk.Align.FILL) bottom_clamp.set_vexpand(False) bottom_clamp.props.maximum_size = 800 bottom_clamp.props.tightening_threshold = 800 bottom_bar.append(bottom_clamp) bottom_inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) bottom_inner.set_hexpand(True) bottom_inner.set_halign(Gtk.Align.FILL) bottom_inner.set_vexpand(False) bottom_clamp.set_child(bottom_inner) self.bottom_inner = bottom_inner # Preview satırı (input'un üstünde) + input aynı genişlik/paddingte dursun pad = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) pad.set_hexpand(True) pad.set_halign(Gtk.Align.FILL) pad.set_vexpand(False) # input ile aynı "iç boşluk" hissi için pad.set_margin_start(8) pad.set_margin_end(8) self.preview_parent = pad bottom_inner.append(pad) # Görünmez holder: preview burada duracak (border/back yok) self.preview_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.preview_holder.set_hexpand(True) self.preview_holder.set_halign(Gtk.Align.FILL) self.preview_holder.set_vexpand(False) # input'a çok yakın dursun self.preview_holder.set_margin_top(0) self.preview_holder.set_margin_bottom(0) pad.append(self.preview_holder) # Preview satırı (holder içinde) self.preview_row.set_valign(Gtk.Align.END) self.preview_row.set_vexpand(False) self.preview_row.set_hexpand(True) self.preview_row.set_halign(Gtk.Align.FILL) # ÖNEMLİ: preview_row üzerindeki marginleri 0 tut self.preview_row.set_margin_start(0) self.preview_row.set_margin_end(0) self.preview_holder.append(self.preview_row) # Input satırı (preview'un altında) input_wrapper = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) input_wrapper.set_hexpand(True) input_wrapper.set_halign(Gtk.Align.FILL) input_wrapper.set_valign(Gtk.Align.END) input_wrapper.set_vexpand(False) pad.append(input_wrapper) # refresh'i pad + preview yerleştikten sonra çağır (senin yaptığın doğru) self.refresh_attachments_preview() # ---------------- SINGLE INPUT CARD ---------------- input_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) input_card.set_hexpand(True) input_card.set_halign(Gtk.Align.FILL) input_card.set_vexpand(False) input_card.add_css_class("chat-input-box") input_wrapper.append(input_card) self.input_card = input_card # --- TOP: TEXT AREA --- self.textview = Gtk.TextView() self.textview.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) self.textview.set_hexpand(True) self.textview.set_vexpand(False) self.textview.set_size_request(260, -1) self.textview.add_css_class("chat-textview") self.setup_file_drop_target(self.textview) self.textview.set_top_margin(4) self.textview.set_bottom_margin(4) self.textview.set_left_margin(2) self.textview.set_right_margin(2) buf = self.textview.get_buffer() buf.connect("changed", self.on_input_buffer_changed) self.text_scroll = Gtk.ScrolledWindow() self.text_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) self.text_scroll.set_propagate_natural_height(True) self.text_scroll.set_min_content_height(35) self.text_scroll.set_max_content_height(180) self.text_scroll.set_min_content_width(260) self.text_scroll.set_max_content_width(9999) self.text_scroll.set_vexpand(False) self.text_scroll.set_hexpand(True) self.text_scroll.set_child(self.textview) input_card.append(self.text_scroll) # --- BOTTOM: CONTROLS ROW --- controls_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) controls_row.set_hexpand(True) controls_row.set_halign(Gtk.Align.FILL) controls_row.set_vexpand(False) input_card.append(controls_row) # sol: attachments attach_btn = Gtk.Button(label="📎") attach_btn.set_valign(Gtk.Align.CENTER) self.bind_i18n(attach_btn, "tooltip", "o_Attachments") attach_btn.add_css_class("flat") attach_btn.connect("clicked", self.open_attach_menu) controls_row.append(attach_btn) self.attach_btn = attach_btn prompt_btn = Gtk.Button(label="🖍️") prompt_btn.set_valign(Gtk.Align.CENTER) self.bind_i18n(prompt_btn, "tooltip", "o_Prompt_Chooser") prompt_btn.add_css_class("flat") prompt_btn.connect("clicked", self.open_prompt_chooser_menu) controls_row.append(prompt_btn) self.prompt_btn = prompt_btn # orta boşluk controls_spacer = Gtk.Box() controls_spacer.set_hexpand(True) controls_row.append(controls_spacer) # sağ: mic sonra send mic_btn = Gtk.Button(label="🎙️") mic_btn.set_valign(Gtk.Align.CENTER) self.bind_i18n(mic_btn, "tooltip", "o_Microphone") mic_btn.connect("clicked", self.toggle_voice_input) mic_btn.add_css_class("flat") controls_row.append(mic_btn) self.mic_btn = mic_btn self._voice_proc = None self._last_wav = None send_btn = Gtk.Button(label="↑") send_btn.set_valign(Gtk.Align.CENTER) self.bind_i18n(send_btn, "tooltip", "o_Send_Button") send_btn.connect("clicked", self.on_send_button_clicked) send_btn.add_css_class("flat") controls_row.append(send_btn) self.send_btn = send_btn # key controller key_controller = Gtk.EventControllerKey() key_controller.connect("key-pressed", self.on_textview_key_pressed) self.textview.add_controller(key_controller) # (Scroll-to-bottom butonu overlay’de kalabilir; margin_bottom’u input yüksekliğine göre ayarla) self.scroll_btn = Gtk.Button(label="↓") self.scroll_btn.set_size_request(36, 36) self.scroll_btn.set_valign(Gtk.Align.END) self.scroll_btn.set_halign(Gtk.Align.END) self.scroll_btn.set_margin_end(20) self.scroll_btn.add_css_class("scroll-btn") self.bind_i18n(self.scroll_btn, "tooltip", "o_Scroll_Down") self.scroll_btn.connect("clicked", self.on_scroll_button_clicked) self.scroll_btn.set_visible(False) overlay.add_overlay(self.scroll_btn) # aktif chat modeli yükle self.active_model = self.get_chat_model(self.current_chat) # load initial self.load_chat_list() self.load_chat() self.apply_chats_visibility() self.load_models_list() self.apply_models_visibility() self.setup_file_drop_target(input_card) self.setup_file_drop_target(self.text_scroll) self.setup_file_drop_target(self.textview) self.setup_file_drop_target(self.content_box) self.setup_file_drop_target(self.scroll) self.win.present() GLib.idle_add(self.force_focus_entry) GLib.idle_add(self._update_bottom_spacer_height) # ---------------- CSS / THEME ---------------- def apply_css(self): colors = self._get_theme_colors() window_bg = colors["window_bg"] input_bg = colors["input_bg"] user_bg = colors["user_bg"] user_text = colors["user_text"] bot_bg = colors["bot_bg"] bot_text = colors["bot_text"] sidebar_text = colors["sidebar_text"] local_model_highlight = colors["local_model_highlight"] bg_path = self._get_chat_bg_image_path().strip() bg_css = "" if bg_path: try: p = Path(bg_path).expanduser().resolve() if p.exists(): uri = p.as_uri() bg_css = f""" .chat-area {{ background-image: url("{uri}"); background-repeat: no-repeat; background-position: center center; background-size: cover; }} """ else: bg_css = """ .chat-area { background-image: none; } """ except Exception: bg_css = """ .chat-area { background-image: none; } """ else: bg_css = """ .chat-area { background-image: none; } """ css = f""" .app-root {{ background: {window_bg}; }} {bg_css} .chat-input-box {{ background: {input_bg}; border-radius: 22px; margin-bottom: 8px; padding: 12px 14px 10px 14px; }} .scroll-btn {{ background: {input_bg}; outline: 1px solid rgba(169,169,169,1); }} window.dark .chat-input-box {{ background: {input_bg}; outline: 1px solid rgba(255,255,255,0.08); }} window.light .chat-input-box {{ background: {input_bg}; outline: 1px solid rgba(0,0,0,0.06); }} .chat-textview {{ background: transparent; }} .attach-chip {{ padding: 6px 10px; margin: 3px; border-radius: 10px; background: rgba(255,255,255,0.06); }} window.light .attach-chip {{ background: rgba(0,0,0,0.06); }} .attach-x {{ padding: 0px; margin: 0px; border-radius: 999px; min-width: 18px; min-height: 18px; font-size: 12px; }} .attach-edit-on {{ padding:6px 10px; margin: 3px; outline: 3px solid #2ecc71; border-radius: 10px; }} .chat-textview {{ background: transparent; }} .selected-chat {{ background: #d0e0ff; border-radius: 6px; }} .sidebar {{ background: {window_bg}; color: {sidebar_text}; padding: 8px; }} .model-local-label {{ color: {local_model_highlight}; }} .user-bubble {{ background: {user_bg}; color: {user_text}; border-radius: 12px 5px 12px 12px; padding: 10px; margin-right: 10px; }} .bot-bubble {{ background: {bot_bg}; color: {bot_text}; border-radius: 5px 12px 12px; padding: 10px; margin-left: 10px; }} .suggestion-wrap {{ margin-top: 120px; }} .suggestion-bubble {{ padding: 10px 14px; border-radius: 999px; background: rgba(255,255,255,0.08); }} window.light .suggestion-bubble {{ background: rgba(0,0,0,0.06); }} .image-zoom-btn {{ min-width: 26px; min-height: 26px; padding: 0; border-radius: 999px; background: rgba(0,0,0,0.45); color: white; }} window.light .image-zoom-btn {{ background: rgba(255,255,255,0.85); color: black; }} .user-bubble-ref {{ background: #237227; /* koyu yeşil gibi (dark mode) */ border: 1px solid rgba(255,255,255,0.08); }} window.light .user-bubble-ref {{ background: #84B179; /* koyu yeşil gibi (dark mode) */ color: #111; border: 1px solid rgba(0,0,0,0.08); }} .action-dim {{ background: rgba(0,0,0,0.58); border-radius: 12px; }} window.light .action-dim {{ background: rgba(255,255,255,0.45); }} .action-center-btn {{ min-width: 34px; min-height: 34px; border-radius: 999px; background: rgba(0,0,0,0.72); color: white; }} window.light .action-center-btn {{ background: rgba(255,255,255,0.92); color: black; }} .user-bubble-regen {{ background: #5a3a8a; /* koyu mor (dark mode) */ border: 1px solid rgba(255,255,255,0.08); }} window.light .user-bubble-regen {{ background: #e9dcff; /* açık mor (light mode) */ color: #111; border: 1px solid rgba(0,0,0,0.08); }} .refs-preview {{ padding: 6px 8px; border-radius: 10px; margin-bottom: 6px; background: rgba(255,255,255,0.06); }} .refs-preview-line {{ opacity: 0.55; font-size: 0.90em; }} window.light .refs-preview {{ background: rgba(0,0,0,0.06); }} window.light .refs-preview-line {{ opacity: 0.65; }} .refs-groups {{ margin-bottom: 6px; }} .hovered {{ box-shadow: 0 0 6px rgba(0,0,0,0.25); }} .selected {{ outline: none; box-shadow: inset 0 0 0 2px #FFD54F; border-radius: 12px; }} .code-block {{ background: #1e1e1e; border-radius: 8px; padding: 12px; font-family: monospace; }} window.light .code-block {{ background: #F9F9F9; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); }} .code-copy-btn {{ color: white; }} window.light .code-copy-btn {{ color: #1e1e1e; }} """ provider = Gtk.CssProvider() provider.load_from_data(css.encode()) display = Gdk.Display.get_default() if display: Gtk.StyleContext.add_provider_for_display( display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ) def apply_theme(self, is_dark: bool): is_dark = bool(is_dark) style_manager = Adw.StyleManager.get_default() style_manager.set_color_scheme( Adw.ColorScheme.FORCE_DARK if is_dark else Adw.ColorScheme.FORCE_LIGHT ) self.win.remove_css_class("dark") self.win.remove_css_class("light") self.win.add_css_class("dark" if is_dark else "light") def toggle_theme(self, *_): cfg = ensure_base_config(self.load_config()) current = cfg["dark_mode"] cfg["dark_mode"] = not current self.save_config(cfg) self.apply_theme(cfg["dark_mode"]) self.refresh_visual_theme_only() # ---------------- INPUT ---------------- def on_input_buffer_changed(self, *_): # Özellikle Ctrl+A + Delete gibi toplu silmelerde # layout iki turda oturabiliyor. GLib.idle_add(self._update_input_layout_after_text_change) GLib.timeout_add(20, self._update_input_layout_after_text_change) def _update_input_layout_after_text_change(self): try: # layout bir tur daha otursun diye spacer hesaplamasını idle'da yap self._update_bottom_spacer_height() except Exception: pass return False def _update_bottom_spacer_height(self, *_): try: card = getattr(self, "input_card", None) spacer = getattr(self, "bottom_spacer", None) if card is None or spacer is None: return False input_height = int(card.get_allocated_height() or 0) # ilk açılış / geçici layout anlarında 0 gelirse # minimum güvenli değere dön if input_height <= 0: input_height = 80 spacer.set_size_request(-1, input_height + 20) # scroll butonu da aynı mantıkla yeniden yerleşsin GLib.idle_add(self._sync_scroll_btn_margin) except Exception: pass return False def force_focus_entry(self): self.textview.grab_focus() return False def on_textview_key_pressed(self, controller, keyval, keycode, state): ctrl = bool(state & Gdk.ModifierType.CONTROL_MASK) shift = bool(state & Gdk.ModifierType.SHIFT_MASK) buffer = self.textview.get_buffer() # Ctrl+C / Ctrl+X / Ctrl+V / Ctrl+A if ctrl: # Ctrl+A: hepsini seç if keyval in (Gdk.KEY_a, Gdk.KEY_A): start, end = buffer.get_bounds() buffer.select_range(start, end) return True # Ctrl+C: seçileni kopyala if keyval in (Gdk.KEY_c, Gdk.KEY_C): if buffer.get_has_selection(): s, e = buffer.get_selection_bounds() text = buffer.get_text(s, e, True) or "" display = Gdk.Display.get_default() if display: display.get_clipboard().set(text) return True # Ctrl+X: kes if keyval in (Gdk.KEY_x, Gdk.KEY_X): if buffer.get_has_selection(): s, e = buffer.get_selection_bounds() text = buffer.get_text(s, e, True) or "" display = Gdk.Display.get_default() if display: display.get_clipboard().set(text) buffer.delete(s, e) return True # Ctrl+V (ve Ctrl+Shift+V): yapıştır if keyval in (Gdk.KEY_v, Gdk.KEY_V): display = Gdk.Display.get_default() if not display: return True clipboard = display.get_clipboard() def on_text(cb, res): try: text = cb.read_text_finish(res) or "" except Exception: text = "" if not text: return # seçili alan varsa üstüne yaz if buffer.get_has_selection(): s, e = buffer.get_selection_bounds() buffer.delete(s, e) it = buffer.get_iter_at_mark(buffer.get_insert()) buffer.insert(it, text) clipboard.read_text_async(None, on_text) return True # Enter davranışın (sende vardı) if keyval == Gdk.KEY_Return: if shift: return False if getattr(self, "is_generating", False): GLib.idle_add(self.stop_ai_generation) else: GLib.idle_add(self.send_message, None) return True 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 update_stream_text(self, text, chat_path_for_ai=None): chat_path_for_ai = Path(chat_path_for_ai or self.current_chat) text = str(text or "") if not text: return False key = self._chat_key(chat_path_for_ai) self.stream_active_chats.add(key) if Path(self.current_chat).resolve() == chat_path_for_ai.resolve(): self.typing_active = False try: self.typing_row.set_visible(False) except Exception: pass try: chat_data = self.load_chat_data_ui(chat_path_for_ai) messages = chat_data["messages"] except Exception: return False bot_idx = None for i in range(len(messages) - 1, -1, -1): msg = messages[i] if ( isinstance(msg, dict) and msg.get("role") in ("bot", "assistant") and msg.get("streaming") ): bot_idx = i break if bot_idx is None: messages.append({ "role": "bot", "content": "", "streaming": True }) bot_idx = len(messages) - 1 messages[bot_idx]["content"] = str(messages[bot_idx].get("content") or "") + text try: chat_data["messages"] = messages self.save_chat_data_ui(chat_data, chat_path_for_ai) except Exception: pass # Ekrandaki aktif chat bu değilse sadece dosyaya yaz, UI'yi elleme if Path(self.current_chat) != chat_path_for_ai: return False # İlk stream parçasında ekranda bubble yoksa sadece bir kere load et if key not in self.stream_ui_ready_chats: self.stream_ui_ready_chats.add(key) self.load_chat() self.scroll_to_bottom(force=True) return False # Sonraki parçalarda tüm chat'i yeniden yükleme. # Bu, paralel chatlerde UI callback'lerinin birbirini beklemesine sebep olur. if Path(self.current_chat).resolve() == chat_path_for_ai.resolve(): self.refresh_visible_streaming_message(chat_path_for_ai) self.scroll_to_bottom(force=True) return False def refresh_visible_streaming_message(self, chat_path_for_ai): try: if Path(self.current_chat).resolve() != Path(chat_path_for_ai).resolve(): return False # Şimdilik güvenli minimal çözüm: # load_chat'i her chunk'ta değil, daha seyrek çalıştır. now = datetime.now().timestamp() last = getattr(self, "_last_stream_ui_refresh", 0) if now - last < 0.12: return False self._last_stream_ui_refresh = now self.load_chat() return False except Exception: return False def finalize_ai_response(self, raw_text: str, chat_path_for_ai=None): chat_path_for_ai = Path(chat_path_for_ai or self.current_chat) raw_text = (raw_text or "").strip() try: data = json.loads(raw_text) except Exception: data = None # stdout içine yanlışlıkla debug satırı karışırsa son JSON objesini yakala start = raw_text.rfind("\n{") if start != -1: candidate = raw_text[start + 1:].strip() else: start = raw_text.find("{") candidate = raw_text[start:].strip() if start != -1 else "" if candidate: try: data = json.loads(candidate) except Exception: data = None if not isinstance(data, dict): data = {"type": "text", "content": raw_text} try: chat_data = self.load_chat_data_ui(chat_path_for_ai) messages = chat_data["messages"] except Exception: chat_data = { "summary": "", "messages": [], "code_context": {}, "memory_chunks": [] } messages = chat_data["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() web_req = data.get("web_search_request") if isinstance(web_req, dict): bot_msg["web_search_request"] = { "query": str(web_req.get("query") or "").strip(), "status": str(web_req.get("status") or "pending").strip(), "err": str(web_req.get("err") or "").strip(), "sources": web_req.get("sources") if isinstance(web_req.get("sources"), list) else [] } else: bot_msg.pop("web_search_request", None) saved_images = [] seen_saved = set() seen_image_keys = 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: b64 = item.split(",", 1)[1].strip() if b64: base64_candidates.append(b64) 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() image_key = item[:200] + str(len(item)) if image_key in seen_image_keys: continue seen_image_keys.add(image_key) 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 if not str(bot_msg.get("content") or "").strip(): bot_msg["content"] = ( self("o_Image_Created") if self("o_Image_Created") != "o_Image_Created" else self("o_Image_Created") ) else: bot_msg.pop("images", None) generated_files = data.get("generated_files") if isinstance(generated_files, list): clean_files = [] for item in generated_files: if not isinstance(item, dict): continue path = str(item.get("path") or "").strip() name = str(item.get("name") or "").strip() if path and Path(path).exists() and Path(path).is_file(): clean_files.append({ "path": str(Path(path).resolve()), "name": name or Path(path).name }) if clean_files: bot_msg["generated_files"] = clean_files else: bot_msg.pop("generated_files", None) else: bot_msg.pop("generated_files", None) messages[bot_idx] = bot_msg try: chat_data["messages"] = messages self.save_chat_data_ui(chat_data, chat_path_for_ai) except Exception as e: print("finalize_ai_response write error:", e) # ---------------- IMAGE,DOCS PREVIEW AND ATTACH MENU ---------------- def is_debian_like(self) -> bool: try: os_release = Path("/etc/os-release") if not os_release.exists(): return False data = os_release.read_text(encoding="utf-8", errors="ignore").lower() keys = [] for line in data.splitlines(): if "=" not in line: continue k, v = line.split("=", 1) v = v.strip().strip('"').strip("'") if k in ("id", "id_like"): keys.append(v) joined = " ".join(keys) return any(x in joined for x in ("debian", "ubuntu", "raspbian", "linuxmint", "pop")) except Exception: return False def should_use_native_file_chooser(self) -> bool: # Debian/Ubuntu/Raspberry tarafında daha stabil fallback return self.is_debian_like() def open_files_dialog_portable(self, title="Dosya Seç", image_only=False, multiple=True, callback=None): """ callback(paths: list[str]) çağrılır """ callback = callback or (lambda paths: None) if self.should_use_native_file_chooser(): self._open_files_dialog_native(title=title, image_only=image_only, multiple=multiple, callback=callback) else: self._open_files_dialog_modern(title=title, image_only=image_only, multiple=multiple, callback=callback) def open_single_file_dialog_portable(self, title="Dosya Seç", image_only=False, callback=None): """ callback(path: str | None) çağrılır """ callback = callback or (lambda path: None) if self.should_use_native_file_chooser(): self._open_single_file_dialog_native(title=title, image_only=image_only, callback=callback) else: self._open_single_file_dialog_modern(title=title, image_only=image_only, callback=callback) def _build_file_filters(self, image_only=False): img_filter = Gtk.FileFilter() img_filter.set_name("Images") img_filter.add_mime_type("image/png") img_filter.add_mime_type("image/jpeg") img_filter.add_mime_type("image/webp") img_filter.add_mime_type("image/gif") img_filter.add_mime_type("image/bmp") img_filter.add_mime_type("image/tiff") any_filter = Gtk.FileFilter() any_filter.set_name("All files") any_filter.add_pattern("*") if image_only: return [img_filter] return [img_filter, any_filter] def _open_files_dialog_modern(self, title="Dosya Seç", image_only=False, multiple=True, callback=None): callback = callback or (lambda paths: None) dialog = Gtk.FileDialog() filters = self._build_file_filters(image_only=image_only) flist = Gio.ListStore.new(Gtk.FileFilter) for f in filters: flist.append(f) dialog.set_filters(flist) if multiple: def on_done(dlg, res): try: files = dlg.open_multiple_finish(res) except Exception: callback([]) return paths = [] if files: for i in range(files.get_n_items()): gfile = files.get_item(i) if not gfile: continue p = gfile.get_path() if p: paths.append(p) callback(paths) dialog.open_multiple(self.win, None, on_done) else: def on_done(dlg, res): try: gfile = dlg.open_finish(res) except Exception: callback([]) return if not gfile: callback([]) return p = gfile.get_path() callback([p] if p else []) dialog.open(self.win, None, on_done) def _open_single_file_dialog_modern(self, title="Dosya Seç", image_only=False, callback=None): callback = callback or (lambda path: None) def _cb(paths): callback(paths[0] if paths else None) self._open_files_dialog_modern(title=title, image_only=image_only, multiple=False, callback=_cb) def _open_files_dialog_native(self, title="Dosya Seç", image_only=False, multiple=True, callback=None): callback = callback or (lambda paths: None) dialog = Gtk.FileChooserDialog( title=title, transient_for=self.win, modal=True, action=Gtk.FileChooserAction.OPEN ) dialog.add_button(self("o_Cancel"), Gtk.ResponseType.CANCEL) dialog.add_button(self("o_Open"), Gtk.ResponseType.ACCEPT) dialog.set_select_multiple(bool(multiple)) for f in self._build_file_filters(image_only=image_only): dialog.add_filter(f) def on_response(dlg, response): paths = [] try: if response == Gtk.ResponseType.ACCEPT: if multiple: model = dlg.get_files() if model: for i in range(model.get_n_items()): gfile = model.get_item(i) if not gfile: continue p = gfile.get_path() if p: paths.append(p) else: gfile = dlg.get_file() if gfile: p = gfile.get_path() if p: paths.append(p) except Exception as e: print("file chooser error:", e) dlg.destroy() callback(paths) dialog.connect("response", on_response) dialog.present() def _open_single_file_dialog_native(self, title="Dosya Seç", image_only=False, callback=None): callback = callback or (lambda path: None) def _cb(paths): callback(paths[0] if paths else None) self._open_files_dialog_native( title=title, image_only=image_only, multiple=False, callback=_cb ) def _measure_text_px(self, widget: Gtk.Widget, text: str) -> int: # widget'in font context'iyle ölçüm yap ctx = widget.get_pango_context() layout = Pango.Layout.new(ctx) layout.set_text(text or "", -1) w, _ = layout.get_pixel_size() return int(w) def show_image_preview(self, image_path): p = str(Path(image_path).resolve()) if Path(p).exists() and p not in self.pending_images: self.pending_images.append(p) self.refresh_attachments_preview() def _capture_photo_from_script(self): script = Path.home() / ".config" / "scripts" / "screenprint.sh" if not script.exists(): GLib.idle_add( self.handle_ai_error, { "role": "bot", "error_key": "o_Script_Not_Found", "error_params": { "path": str(script) } } ) return watch_dirs = [Path.home() / "Resimler", Path.home() / "Pictures", Path("/tmp")] def collect_candidates(): out = [] for d in watch_dirs: if not d.exists(): continue out += list(d.glob("screen*.*")) out += list(d.glob("capture*.*")) out += list(d.glob("shot*.*")) out += list(d.glob("*.png")) out += list(d.glob("*.jpg")) out += list(d.glob("*.jpeg")) return [p for p in out if p.exists() and p.is_file()] try: # Script çalışmadan önce mevcut dosyaları kaydet before_files = {} for pth in collect_candidates(): try: before_files[str(pth.resolve())] = pth.stat().st_mtime except Exception: pass start_ts = datetime.now().timestamp() p = subprocess.run( [str(script), "only-one"], capture_output=True, text=True ) # İptal veya hata: eski resmi seçme if p.returncode != 0: return out = (p.stdout or "").strip() # 1) Script stdout'a path bastıysa sadece gerçekten yeniyse kabul et if out: try: cand = Path(out).expanduser().resolve() if cand.exists() and cand.is_file(): old_mtime = before_files.get(str(cand)) new_mtime = cand.stat().st_mtime if old_mtime is None or new_mtime > old_mtime or new_mtime >= start_ts: GLib.idle_add(self.show_image_preview, str(cand)) return except Exception: pass # 2) Fallback: sadece bu işlem sırasında oluşan / güncellenen dosyaları al after_files = [] for cand in collect_candidates(): try: resolved = str(cand.resolve()) mtime = cand.stat().st_mtime old_mtime = before_files.get(resolved) is_new = old_mtime is None is_updated = old_mtime is not None and mtime > old_mtime is_after_start = mtime >= start_ts if (is_new or is_updated) and is_after_start: after_files.append(cand) except Exception: pass # Yeni dosya yoksa hiçbir şey yapma if not after_files: return newest = max(after_files, key=lambda x: x.stat().st_mtime) GLib.idle_add(self.show_image_preview, str(newest.resolve())) except Exception as e: GLib.idle_add( self.handle_ai_error, { "role": "bot", "error_key": "o_Photo_Capture_Error", "error_params": { "error": str(e) } } ) def open_attach_menu(self, button: Gtk.Button): pop = Gtk.Popover() pop.set_parent(button) pop.set_position(Gtk.PositionType.TOP) pop.set_has_arrow(True) pop.set_autohide(True) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) box.set_margin_top(8) box.set_margin_bottom(8) box.set_margin_start(8) box.set_margin_end(8) btn_photo = Gtk.Button(label=f"📸 {self('o_Take_Photo')}") btn_file = Gtk.Button(label=f"📄 {self('o_Attach_File')}") btn_photo.set_halign(Gtk.Align.FILL) btn_file.set_halign(Gtk.Align.FILL) # bubble click'e taşmasın self._consume_click(btn_photo) self._consume_click(btn_file) def _photo_clicked(_b): pop.popdown() # UI donmasın diye thread threading.Thread(target=self._capture_photo_from_script, daemon=True).start() def _file_clicked(_b): pop.popdown() # senin mevcut dosya seçicini aç GLib.idle_add(self.on_attach_clicked) btn_photo.connect("clicked", _photo_clicked) btn_file.connect("clicked", _file_clicked) box.append(btn_photo) box.append(btn_file) pop.set_child(box) pop.popup() def setup_file_drop_target(self, widget): from urllib.parse import urlparse, unquote formats = Gdk.ContentFormats.new([ "text/uri-list", "x-special/gnome-copied-files", ]) drop_target = Gtk.DropTargetAsync.new( formats, Gdk.DragAction.COPY ) def parse_uri_text(text: str): paths = [] for line in (text or "").splitlines(): line = line.strip() if not line or line.startswith("#"): continue if line in ("copy", "cut"): continue if line.startswith("file://"): line = unquote(urlparse(line).path) p = Path(line).expanduser() if p.exists() and p.is_file(): paths.append(str(p.resolve())) return paths def on_accept(_target, drop): print("DROP ACCEPT:", drop.get_formats().to_string(), flush=True) return True def on_drag_enter(_target, drop, x, y): print("DROP ENTER:", drop.get_formats().to_string(), flush=True) return Gdk.DragAction.COPY def on_drag_motion(_target, drop, x, y): print("DROP MOTION", flush=True) return Gdk.DragAction.COPY def on_drop(_target, drop, _x, _y): print("ASYNC DROP GELDI", flush=True) print("DROP FORMATS:", drop.get_formats().to_string(), flush=True) def finish_paths(paths): paths = [p for p in paths if p and Path(p).exists() and Path(p).is_file()] print("DROP PATHS:", paths, flush=True) ok = bool(paths) if ok: self.add_dropped_files(paths) GLib.idle_add(self.refresh_attachments_preview) GLib.idle_add(self._sync_scroll_btn_margin) drop.finish(Gdk.DragAction.COPY if ok else 0) # 1) Önce Gdk.FileList oku def on_filelist_read(drop_obj, result): try: value = drop_obj.read_value_finish(result) files = value.get_files() paths = [] for gfile in files: p = gfile.get_path() if p: paths.append(str(Path(p).resolve())) finish_paths(paths) return except Exception as e: print("Gdk.FileList read error:", e, flush=True) # 2) Olmazsa Gio.File oku def on_gfile_read(drop_obj2, result2): try: value2 = drop_obj2.read_value_finish(result2) p = value2.get_path() finish_paths([str(Path(p).resolve())] if p else []) return except Exception as e2: print("Gio.File read error:", e2, flush=True) # 3) En son text/uri-list oku def on_text_read(drop_obj3, result3): try: value3 = drop_obj3.read_value_finish(result3) text = str(value3 or "") print("DROP RAW TEXT:", repr(text), flush=True) finish_paths(parse_uri_text(text)) return except Exception as e3: print("text/uri-list read error:", e3, flush=True) drop.finish(False) drop.read_value_async( str, GLib.PRIORITY_DEFAULT, None, on_text_read ) drop.read_value_async( Gio.File, GLib.PRIORITY_DEFAULT, None, on_gfile_read ) drop.read_value_async( Gdk.FileList, GLib.PRIORITY_DEFAULT, None, on_filelist_read ) return True drop_target.connect("accept", on_accept) drop_target.connect("drag-enter", on_drag_enter) drop_target.connect("drag-motion", on_drag_motion) drop_target.connect("drop", on_drop) widget.add_controller(drop_target) def get_prompt_chooser_blocks(self) -> list[str]: cfg = ensure_base_config(self.load_config()) blocks = cfg.get("prompt_chooser_blocks", ["copyable"]) if not isinstance(blocks, list): blocks = ["copyable"] valid = { "copyable", "apply", "file_create", "web_search", "structured", "code", "pdf_text", "pdf_image", "pdf_text_image", } return [str(x) for x in blocks if str(x) in valid] def save_prompt_chooser_blocks(self, blocks: list[str]): cfg = ensure_base_config(self.load_config()) cfg["prompt_chooser_blocks"] = list(blocks) self.save_config(cfg) def open_prompt_chooser_menu(self, button: Gtk.Button): pop = Gtk.Popover() pop.set_parent(button) pop.set_position(Gtk.PositionType.TOP) pop.set_has_arrow(True) pop.set_autohide(True) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) box.set_margin_top(10) box.set_margin_bottom(10) box.set_margin_start(10) box.set_margin_end(10) title = Gtk.Label(label=self("o_Prompt_Chooser")) title.set_xalign(0) title.add_css_class("dim-label") box.append(title) selected = set(self.get_prompt_chooser_blocks()) items = [ ("copyable", "o_PC_Copyable"), ("apply", "o_PC_Apply"), ("file_create", "o_PC_FileCreate"), ("web_search", "o_PC_WebSearch"), ("structured", "o_PC_Structured"), ("code", "o_PC_Code"), ("pdf_text", "o_PC_PDFText"), ("pdf_image", "o_PC_PDFImage"), ("pdf_text_image", "o_PC_PDFTextImage"), ] checks = {} def refresh_config(*_): new_selected = [] for key, check in checks.items(): if check.get_active(): new_selected.append(key) # Boş listeye izin var self.save_prompt_chooser_blocks(new_selected) for key, label in items: check = Gtk.CheckButton(label=self(label)) check.set_active(key in selected) check.set_halign(Gtk.Align.START) check.connect("toggled", refresh_config) checks[key] = check box.append(check) pop.set_child(box) pop.popup() def add_dropped_files(self, paths): import mimetypes changed = False for path in paths: if not path: continue p = Path(path).expanduser() if not p.exists() or not p.is_file(): continue path_str = str(p.resolve()) mime, _ = mimetypes.guess_type(path_str) mime = (mime or "").lower() if mime.startswith("image/"): if path_str not in self.pending_images: self.pending_images.append(path_str) changed = True else: if not any(x.get("path") == path_str for x in self.pending_files): self.pending_files.append({ "path": path_str, "name": p.name, "edit": False }) changed = True if changed: print("PENDING IMAGES:", self.pending_images) print("PENDING FILES:", self.pending_files) self.refresh_attachments_preview() GLib.idle_add(self.refresh_attachments_preview) GLib.idle_add(self._sync_scroll_btn_margin) def on_attach_clicked(self, *_): def on_paths_selected(paths): if not paths: return import mimetypes for path in paths: if not path: continue mime, _ = mimetypes.guess_type(path) mime = (mime or "").lower() if mime.startswith("image/"): if path not in self.pending_images: self.pending_images.append(path) else: if not any(x.get("path") == path for x in self.pending_files): self.pending_files.append({ "path": path, "name": Path(path).name, "edit": False }) self.refresh_attachments_preview() self.open_files_dialog_portable( title=self("o_Attach_File"), image_only=False, multiple=True, callback=on_paths_selected ) def refresh_attachments_preview(self): try: # Preview her zaman preview_holder içinde kalsın holder = getattr(self, "preview_holder", None) if holder and self.preview_row: parent = self.preview_row.get_parent() if parent is not holder: if parent is not None: try: parent.remove(self.preview_row) except Exception: pass holder.append(self.preview_row) except Exception: pass was_at_bottom = self.is_at_bottom() # temizle if not hasattr(self, "preview_hbox"): return while True: c = self.preview_hbox.get_first_child() if not c: break self.preview_hbox.remove(c) has_any = bool(self.pending_images or self.pending_files) self.preview_row.set_visible(has_any) if not has_any: if hasattr(self, "preview_next_btn"): self.preview_next_btn.set_visible(False) return # görseller for p in list(self.pending_images): img_path = Path(p) if not img_path.exists(): continue # Kart içeriği item = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) item.set_halign(Gtk.Align.START) item.set_hexpand(False) item.set_vexpand(False) item.set_size_request(110, -1) pic_overlay = self.build_zoomable_image_widget(str(img_path.resolve()), 96, 72) pic_overlay.set_hexpand(False) pic_overlay.set_vexpand(False) pic_overlay.set_halign(Gtk.Align.START) name = Gtk.Label(label=img_path.name) name.set_xalign(0) name.set_hexpand(False) name.set_halign(Gtk.Align.START) name.set_wrap(False) name.set_single_line_mode(True) name.set_max_width_chars(18) name.set_ellipsize(Pango.EllipsizeMode.END) item.append(pic_overlay) item.append(name) # Overlay: sağ üst X overlay_item = Gtk.Overlay() overlay_item.set_child(item) xbtn = Gtk.Button(label="✕") xbtn.add_css_class("flat") xbtn.set_focusable(False) xbtn.set_halign(Gtk.Align.END) xbtn.set_valign(Gtk.Align.START) xbtn.set_margin_top(2) xbtn.set_margin_end(2) xbtn.set_size_request(22, 22) self._consume_click(xbtn) def _rm_img(_b, path=str(img_path.resolve())): try: if path in self.pending_images: self.pending_images.remove(path) except Exception: pass self.refresh_attachments_preview() xbtn.connect("clicked", _rm_img) overlay_item.add_overlay(xbtn) self.preview_hbox.append(overlay_item) # belgeler for f in self.pending_files: p = Path(f.get("path","")) if not p.exists(): continue chip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) chip.add_css_class("attach-chip") chip.set_hexpand(False) chip.set_halign(Gtk.Align.START) if f.get("edit"): chip.add_css_class("attach-edit-on") icon = Gtk.Image.new_from_icon_name("text-x-generic-symbolic") chip.append(icon) lab = Gtk.Label(label=p.name) lab.set_xalign(0.0) lab.set_single_line_mode(True) lab.set_wrap(False) lab.set_ellipsize(Pango.EllipsizeMode.END) lab.set_hexpand(True) lab.set_halign(Gtk.Align.FILL) chip.append(lab) # ---- CHIP WIDTH: text ölç -> clamp (min/max) ---- MIN_PX = 140 MAX_PX = 180 # metnin px genişliği text_px = self._measure_text_px(lab, p.name) # icon + spacing + padding + X butonu için pay (biraz cömert bırak) EXTRA = 22 + 6 + 24 + 18 # icon(22) + spacing(6) + x alanı(24) + padding payı(18) target = max(MIN_PX, min(MAX_PX, text_px + EXTRA)) chip.set_size_request(target, -1) # tıkla -> edit toggle click = Gtk.GestureClick() def _on_chip_clicked(_g, _n, _x, _y, item=f): item["edit"] = not bool(item.get("edit")) self.refresh_attachments_preview() click.connect("pressed", _on_chip_clicked) chip.add_controller(click) overlay_chip = Gtk.Overlay() overlay_chip.set_child(chip) xbtn = Gtk.Button(label="✕") xbtn.add_css_class("attach-x") xbtn.set_focusable(False) xbtn.set_halign(Gtk.Align.END) xbtn.set_valign(Gtk.Align.START) xbtn.set_margin_top(2) xbtn.set_margin_end(2) xbtn.set_size_request(22, 22) self._consume_click(xbtn) def _rm_file(_b, path=str(p.resolve())): try: self.pending_files = [x for x in self.pending_files if str(Path(x.get("path","")).resolve()) != path] except Exception: pass self.refresh_attachments_preview() xbtn.connect("clicked", _rm_file) overlay_chip.add_overlay(xbtn) self.preview_hbox.append(overlay_chip) # taşma var mı? (butonu ona göre aç/kapat) GLib.idle_add(self._update_preview_overflow) if was_at_bottom: GLib.idle_add(self.scroll_to_bottom, True) GLib.idle_add(self._sync_scroll_btn_margin) def _update_preview_overflow(self): try: hadj = self.preview_scroller.get_hadjustment() if not hadj: return False upper = hadj.get_upper() page = hadj.get_page_size() val = hadj.get_value() # taşma var mı? overflow = upper > page + 2 # sola kaydırılabiliyor mu? can_left = overflow and (val > 2) # sağa kaydırılabiliyor mu? max_val = max(0, upper - page) can_right = overflow and (val < max_val - 2) if hasattr(self, "preview_prev_btn"): self.preview_prev_btn.set_visible(bool(can_left)) if hasattr(self, "preview_next_btn"): self.preview_next_btn.set_visible(bool(can_right)) except Exception: pass return False def scroll_preview_right(self, *_): try: hadj = self.preview_scroller.get_hadjustment() if not hadj: return step = max(120, hadj.get_page_size() * 0.75) hadj.set_value(min(hadj.get_upper() - hadj.get_page_size(), hadj.get_value() + step)) except Exception: pass def scroll_preview_left(self, *_): try: hadj = self.preview_scroller.get_hadjustment() if not hadj: return step = max(120, hadj.get_page_size() * 0.75) hadj.set_value(max(0, hadj.get_value() - step)) except Exception: pass # ---------------- AI ERROR / TYPING ---------------- def handle_ai_error(self, error_text, chat_path_for_ai=None): chat_path_for_ai = Path(chat_path_for_ai or self.current_chat) key = self._chat_key(chat_path_for_ai) self.stream_active_chats.discard(key) self.stream_ui_ready_chats.discard(key) self.ai_procs.pop(key, None) self.set_generating_state(False, chat_path_for_ai) if Path(self.current_chat).resolve() == chat_path_for_ai.resolve(): self.typing_active = False is_active_chat = Path(self.current_chat).resolve() == chat_path_for_ai.resolve() if is_active_chat: try: self.typing_row.set_visible(False) except Exception: pass try: chat_data = self.load_chat_data_ui(chat_path_for_ai) messages = chat_data["messages"] except Exception: chat_data = { "summary": "", "messages": [], "code_context": {}, "memory_chunks": [] } messages = chat_data["messages"] # Error gelince bekleyen streaming placeholder'ı temizle for i in range(len(messages) - 1, -1, -1): msg = messages[i] if not isinstance(msg, dict): continue if ( msg.get("role") in ("bot", "assistant") and msg.get("streaming") ): messages.pop(i) break if msg.get("role") == "user": break err_msg = None # 1) Doğrudan dict geldiyse if isinstance(error_text, dict): err_msg = dict(error_text) else: # 2) JSON string geldiyse çöz parsed = None try: parsed = json.loads(str(error_text)) except Exception: parsed = None if isinstance(parsed, dict): # structured error if parsed.get("error_key"): err_msg = { "role": "bot", "error_key": parsed.get("error_key"), "error_params": parsed.get("error_params") or {} } # backend'den gelen type=text elif parsed.get("type") == "text": err_msg = { "role": "bot", "error_key": "o_Error_With_Detail", "error_params": { "error": str(parsed.get("content") or "").strip() } } # 3) Hiçbiri değilse düz string fallback if not isinstance(err_msg, dict): err_msg = { "role": "bot", "error_key": "o_Error_With_Detail", "error_params": { "error": str(error_text or "").strip() } } messages.append(err_msg) try: chat_data["messages"] = messages self.save_chat_data_ui(chat_data, chat_path_for_ai) except Exception: pass if Path(self.current_chat) == chat_path_for_ai: self.load_chat() self.scroll_to_bottom(force=True) def animate_typing(self): if not getattr(self, "typing_active", False): self._typing_timer_active = False return False current_key = self._chat_key(self.current_chat) if current_key in self.stream_active_chats: self.typing_active = False try: self.typing_row.set_visible(False) except Exception: pass self._typing_timer_active = False return False dots = "." * (self.typing_dots % 4) self.typing_label.set_text(f"{self('o_Thinking')}{dots}") self.typing_dots += 1 return True def show_typing_indicator(self, chat_path=None): chat_path = Path(chat_path or self.current_chat) key = self._chat_key(chat_path) # Bu chat zaten stream'e geçtiyse thinking gösterme if key in self.stream_active_chats: return # Sadece ekrandaki aktif chat için görünür yap if Path(self.current_chat).resolve() != chat_path.resolve(): return self.typing_active = True self.typing_dots = 0 self.typing_label.set_text(self("o_Thinking")) self.typing_row.set_visible(True) if not getattr(self, "_typing_timer_active", False): self._typing_timer_active = True GLib.timeout_add(500, self.animate_typing) def hide_typing_indicator(self): self.typing_active = False try: self.typing_row.set_visible(False) except Exception: pass def chat_has_streaming_placeholder(self, chat_path=None) -> bool: try: chat_data = self.load_chat_data_ui(chat_path or self.current_chat) messages = chat_data.get("messages", []) for msg in reversed(messages): if not isinstance(msg, dict): continue if msg.get("role") in ("bot", "assistant") and msg.get("streaming"): return True # Son gerçek kullanıcıdan önceye geçme if msg.get("role") == "user": break except Exception: pass return False def after_ai_response(self, chat_path_for_ai=None): chat_path_for_ai = Path(chat_path_for_ai or self.current_chat) key = self._chat_key(chat_path_for_ai) if Path(self.current_chat).resolve() == chat_path_for_ai.resolve(): self.hide_typing_indicator() if key in self.ai_stop_requested_chats: try: chat_data = self.load_chat_data_ui(chat_path_for_ai) messages = chat_data["messages"] messages.append({ "role": "info", "i18n_key": "o_Cancelled" }) chat_data["messages"] = messages self.save_chat_data_ui(chat_data, chat_path_for_ai) except Exception: pass self.set_generating_state(False, chat_path_for_ai) self.ai_procs.pop(key, None) self.ai_stop_requested_chats.discard(key) self.stream_active_chats.discard(key) self.stream_ui_ready_chats.discard(key) if Path(self.current_chat).resolve() == chat_path_for_ai.resolve(): self.streaming_row = None self.streaming_label = None if Path(self.current_chat) == chat_path_for_ai: try: chat_data = self.load_chat_data_ui(chat_path_for_ai) messages = chat_data["messages"] for msg in reversed(messages): if ( isinstance(msg, dict) and msg.get("role") in ("bot", "assistant") and msg.get("streaming") ): msg.pop("streaming", None) break chat_data["messages"] = messages self.save_chat_data_ui(chat_data, chat_path_for_ai) except Exception: pass self.load_chat() self.scroll_to_bottom(force=True) # ---------------- MODELS LIST ---------------- def apply_models_visibility(self): if not getattr(self, "sidebar_expanded", True): try: self.context_mode_row.set_visible(False) except Exception: pass self.models_header.set_visible(False) self.models_list.set_visible(False) return try: self.context_mode_row.set_visible(True) except Exception: pass self.models_header.set_visible(True) self.models_list.set_visible(self.models_list_open) self.models_toggle_btn.set_label( f"{self('o_AI_Models')} ▾" if self.models_list_open else f"{self('o_AI_Models')} ▸" ) def on_model_row_activated(self, listbox, row): if not row: return model_id = getattr(row, "model_id", None) if not model_id: return cfg = ensure_base_config(self.load_config()) found = self._find_model_entry(cfg, model_id) is_local = bool(found.get("local", False)) if found else False self.set_chat_model(self.current_chat, model_id, is_local) def toggle_models_list(self, *_): self.models_list_open = not self.models_list_open self.apply_models_visibility() def refresh_context_mode_switch(self): try: if not hasattr(self, "context_mode_switch"): return mode = self.get_context_mode(self.current_chat) # switch kapalı: rag # switch açık: direct self.context_mode_switch.set_active(mode == "direct") except Exception: pass def on_context_mode_switch_changed(self, switch, _pspec): mode = "direct" if switch.get_active() else "rag" self.set_context_mode(mode, self.current_chat) def confirm_delete_model(self, button, model_id: str): cfg = ensure_base_config(self.load_config()) models = self._ensure_min_one_model(cfg) if len(models) <= 1: dialog = Gtk.Dialog() dialog.set_transient_for(self.win) self.bind_i18n(dialog, "title", "o_Min_Model_Title") dialog.set_modal(True) content = dialog.get_content_area() label = Gtk.Label() self.bind_i18n(label, "label", "o_Min_Model_Error") content.append(label) dialog.add_button(self("o_OK"), Gtk.ResponseType.OK) dialog.connect("response", lambda d, r: d.destroy()) dialog.present() return dialog = Gtk.Dialog() dialog.set_transient_for(self.win) self.bind_i18n(dialog, "title", "o_Delete_Model") dialog.set_modal(True) content = dialog.get_content_area() text = self("o_Delete_Model_Confirm").format(model_id=model_id) label = Gtk.Label(label=text) label.set_wrap(True) content.append(label) dialog.add_button(self("o_Cancel"), Gtk.ResponseType.CANCEL) dialog.add_button(self("o_Delete"), Gtk.ResponseType.OK) def on_response(d, resp): if resp == Gtk.ResponseType.OK: cfg2 = ensure_base_config(self.load_config()) models2 = self._ensure_min_one_model(cfg2) if len(models2) <= 1: d.destroy() return models2 = [m for m in models2 if m["id"] != model_id] cfg2["ai_models"] = models2 new_default = { "id": str(models2[0]["id"]), "local": bool(models2[0].get("local", False)) } if models2 else { "id": "google/gemini-2.5-flash-image", "local": False } chat_models = cfg2["chat_models"] for k, v in list(chat_models.items()): if isinstance(v, dict): vid = str(v.get("id") or "").strip() if vid == model_id: chat_models[k] = dict(new_default) elif isinstance(v, str) and v == model_id: chat_models[k] = dict(new_default) cfg2["chat_models"] = chat_models self.save_config(cfg2) self.active_model = self.get_chat_model(self.current_chat) self.load_models_list() self.apply_models_visibility() d.destroy() dialog.connect("response", on_response) dialog.present() def open_add_model_dialog(self, *_): dialog = Gtk.Dialog() dialog.set_transient_for(self.win) self.bind_i18n(dialog, "title", "o_Add_Model") dialog.set_modal(True) content = dialog.get_content_area() info = Gtk.Label(label="Model ID:") info.set_wrap(True) info.set_xalign(0) content.append(info) entry = Gtk.Entry() entry.set_placeholder_text("provider/model (exp: openai/gpt-4.1-mini)") content.append(entry) local_check = Gtk.CheckButton(label="Local model") content.append(local_check) # Ctrl+V ile yapıştırmayı garanti et key_controller = Gtk.EventControllerKey() def on_key_pressed(controller, keyval, keycode, state): ctrl = bool(state & Gdk.ModifierType.CONTROL_MASK) # hem v hem V yakala if ctrl and keyval in (Gdk.KEY_v, Gdk.KEY_V): display = Gdk.Display.get_default() if not display: return False clipboard = display.get_clipboard() def on_text(cb, res): try: text = cb.read_text_finish(res) or "" except Exception: text = "" if text: entry.set_text(text.strip()) clipboard.read_text_async(None, on_text) return True return False key_controller.connect("key-pressed", on_key_pressed) entry.add_controller(key_controller) # Link link = Gtk.LinkButton.new_with_label( "https://openrouter.ai/models", "OpenRouter – Models Page" ) link.set_halign(Gtk.Align.CENTER) content.append(link) dialog.add_button(self("o_Cancel"), Gtk.ResponseType.CANCEL) dialog.add_button(self("o_Add"), Gtk.ResponseType.OK) def on_response(d, resp): if resp == Gtk.ResponseType.OK: model_id = entry.get_text().strip() if model_id: is_local = local_check.get_active() self.set_chat_model(self.current_chat, model_id, is_local) d.destroy() dialog.connect("response", on_response) dialog.present() def _focus(): entry.grab_focus() return False GLib.idle_add(_focus) def load_models_list(self): while True: row = self.models_list.get_first_child() if not row: break self.models_list.remove(row) cfg = ensure_base_config(self.load_config()) models = self._ensure_min_one_model(cfg) active = self.get_chat_model(self.current_chat) self.active_model = active for model in models: model_id = str(model["id"]) is_local = bool(model.get("local", False)) row = Gtk.ListBoxRow() row.model_id = model_id box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) box.set_hexpand(True) prefix = "✓ " if active == model_id else "" label = Gtk.Label(label=prefix + model_id) label.set_xalign(0) label.set_hexpand(True) label.set_halign(Gtk.Align.FILL) label.set_single_line_mode(True) label.set_ellipsize(Pango.EllipsizeMode.END) label.set_max_width_chars(22) if is_local: label.add_css_class("model-local-label") box.append(label) trash_btn = Gtk.Button(label="🗑") self.bind_i18n(trash_btn, "tooltip", "o_Delete_Model") trash_btn.set_size_request(30, 30) trash_btn.add_css_class("flat") trash_btn.connect("clicked", self.confirm_delete_model, model_id) box.append(trash_btn) row.set_child(box) self.models_list.append(row) # ---------------- RENAME POPOVER (ESKİ ÖZELLİK) ---------------- def open_rename_popover(self, anchor_btn: Gtk.Button, file_path: Path, menu_popover: Gtk.Popover): menu_popover.popdown() rename_pop = Gtk.Popover() rename_pop.set_parent(anchor_btn) rename_pop.set_position(Gtk.PositionType.BOTTOM) rename_pop.set_has_arrow(True) rename_pop.set_autohide(False) vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) vbox.set_margin_top(10) vbox.set_margin_bottom(10) vbox.set_margin_start(10) vbox.set_margin_end(10) title = Gtk.Label() self.bind_i18n(title, "label", "o_New_Name") title.set_halign(Gtk.Align.START) vbox.append(title) entry = Gtk.Entry() entry.set_placeholder_text("exp: my_chat") entry.set_text(file_path.stem) key_controller = Gtk.EventControllerKey() def on_key_pressed(_controller, keyval, _keycode, state): ctrl = bool(state & Gdk.ModifierType.CONTROL_MASK) # Ctrl değilse bırak GTK normal davransın if not ctrl: return False display = Gdk.Display.get_default() if not display: return False clipboard = display.get_clipboard() # Ctrl+A: hepsini seç if keyval in (Gdk.KEY_a, Gdk.KEY_A): entry.select_region(0, -1) return True # Ctrl+V: yapıştır if keyval in (Gdk.KEY_v, Gdk.KEY_V): def on_text(cb, res): try: text = cb.read_text_finish(res) or "" except Exception: text = "" if not text: return # seçili alan varsa sil sel = entry.get_selection_bounds() if sel: s, e = sel entry.delete_text(s, e) pos = entry.get_position() entry.insert_text(text, pos) entry.set_position(pos + len(text)) clipboard.read_text_async(None, on_text) return True # Ctrl+X: kes (seçiliyse seçiliyi, değilse tümünü) if keyval in (Gdk.KEY_x, Gdk.KEY_X): cur = entry.get_text() or "" if not cur: return True sel = entry.get_selection_bounds() if sel: s, e = sel cut = cur[s:e] clipboard.set(cut) entry.delete_text(s, e) else: clipboard.set(cur) entry.set_text("") return True return False key_controller.connect("key-pressed", on_key_pressed) entry.add_controller(key_controller) def do_rename(*_): self.rename_chat_file(file_path, entry.get_text(), rename_pop) entry.connect("activate", do_rename) vbox.append(entry) hint = Gtk.Label() self.bind_i18n(hint, "label", "o_Rename_Hint") hint.set_halign(Gtk.Align.START) hint.add_css_class("dim-label") vbox.append(hint) btn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) btn_row.set_halign(Gtk.Align.END) cancel_btn = Gtk.Button(label=f"{self('o_Cancel')}") save_btn = Gtk.Button(label=f"{self('o_Save')}") save_btn.add_css_class("suggested-action") cancel_btn.connect("clicked", lambda *_: rename_pop.popdown()) save_btn.connect("clicked", do_rename) btn_row.append(cancel_btn) btn_row.append(save_btn) vbox.append(btn_row) rename_pop.set_child(vbox) rename_pop.popup() # focus/select def _focus(): entry.select_region(0, -1) entry.grab_focus() return False GLib.idle_add(_focus) def rename_chat_file(self, old_path: Path, new_name_raw: str, pop: Gtk.Popover): new_name = (new_name_raw or "").strip() if not new_name: return for ch in ["/", "\\", "\n", "\r", "\t"]: new_name = new_name.replace(ch, "_") new_path = old_path.with_name(new_name + ".json") if new_path == old_path: pop.popdown() return if new_path.exists(): print(f"Rename failed: {new_path.name} zaten var.") return try: old_path.rename(new_path) except Exception as e: print("Rename error:", e) return # pinned update + last_chat update + chat_models update cfg = ensure_base_config(self.load_config()) plist = cfg["pinned_chats"] if old_path.name in plist: cfg["pinned_chats"] = [new_path.name if x == old_path.name else x for x in plist] if cfg["last_chat"] == old_path.name: cfg["last_chat"] = new_path.name chat_models = cfg["chat_models"] if isinstance(chat_models, dict) and old_path.name in chat_models: chat_models[new_path.name] = chat_models.pop(old_path.name) cfg["chat_models"] = chat_models chat_rag_switch = cfg.setdefault("chat_rag_switch", {}) if old_path.name in chat_rag_switch: chat_rag_switch[new_path.name] = chat_rag_switch.pop(old_path.name) cfg["chat_rag_switch"] = chat_rag_switch self.save_config(cfg) if self.current_chat == old_path: self.current_chat = new_path self.active_model = self.get_chat_model(self.current_chat) pop.popdown() self.load_chat_list() self.load_models_list() self.load_chat() # ---------------- PIN TOGGLE (ESKİ ÖZELLİK) ---------------- def toggle_pin_chat(self, file_path: Path): cfg = ensure_base_config(self.load_config()) pinned = cfg["pinned_chats"] name = file_path.name if name in pinned: pinned.remove(name) else: pinned.append(name) cfg["pinned_chats"] = pinned self.save_config(cfg) self.load_chat_list() # ---------------- SETTINGS ---------------- def refresh_all_i18n(self, root=None): if root is None: root = getattr(self, "win", None) if root is None: return self.apply_i18n_to_widget(root) try: child = root.get_first_child() except Exception: child = None while child is not None: self.refresh_all_i18n(child) try: child = child.get_next_sibling() except Exception: break def refresh_dynamic_i18n_texts(self): try: self.models_toggle_btn.set_label( f"{self('o_AI_Models')} ▾" if self.models_list_open else f"{self('o_AI_Models')} ▸" ) except Exception: pass try: self.chats_toggle_btn.set_label( f"{self('o_Chats')} ▾" if self.chats_list_open else f"{self('o_Chats')} ▸" ) except Exception: pass try: if getattr(self, "typing_active", False): self.typing_label.set_text(f"{self('o_Thinking')}...") else: self.typing_label.set_text(self("o_Thinking")) except Exception: pass try: self.set_generating_state(self.is_generating) except Exception: pass def bind_i18n(self, widget, field: str, key: str): """ field: - label - tooltip - title - text - placeholder """ setattr(widget, f"_i18n_{field}_key", key) self.apply_i18n_to_widget(widget) def apply_i18n_to_widget(self, widget): try: label_key = getattr(widget, "_i18n_label_key", None) if label_key: text = self(str(label_key)) if hasattr(widget, "set_label"): widget.set_label(text) except Exception: pass try: tooltip_key = getattr(widget, "_i18n_tooltip_key", None) if tooltip_key: text = self(str(tooltip_key)) if hasattr(widget, "set_tooltip_text"): widget.set_tooltip_text(text) except Exception: pass try: title_key = getattr(widget, "_i18n_title_key", None) if title_key: text = self(str(title_key)) if hasattr(widget, "set_title"): widget.set_title(text) except Exception: pass try: text_key = getattr(widget, "_i18n_text_key", None) if text_key: text = self(str(text_key)) if hasattr(widget, "set_text"): widget.set_text(text) except Exception: pass try: placeholder_key = getattr(widget, "_i18n_placeholder_key", None) if placeholder_key: text = self(str(placeholder_key)) if hasattr(widget, "set_placeholder_text"): widget.set_placeholder_text(text) except Exception: pass def refresh_ui_texts(self): self.refresh_all_i18n() self.refresh_dynamic_i18n_texts() try: self.load_chat_list() except Exception: pass try: self.load_models_list() except Exception: pass try: self.load_chat() except Exception: pass def open_stt_settings_dialog(self): cfg = ensure_base_config(self.load_config()) dialog = Gtk.Dialog(transient_for=self.win) self.bind_i18n(dialog, "title", "o_STT_Settings") dialog.set_modal(True) dialog.set_default_size(520, 380) content = dialog.get_content_area() content.set_spacing(10) content.set_margin_top(10) content.set_margin_bottom(10) content.set_margin_start(10) content.set_margin_end(10) # --- Online STT --- row_online = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) online_label_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) online_label_box.set_hexpand(True) online_title = Gtk.Label() self.bind_i18n(online_title, "label", "o_Online_STT_Text") online_title.set_xalign(0) online_title.set_halign(Gtk.Align.START) online_desc = Gtk.Label() self.bind_i18n(online_desc, "label", "o_Online_STT_Hint") online_desc.set_xalign(0) online_desc.set_halign(Gtk.Align.START) online_desc.set_wrap(True) online_desc.add_css_class("dim-label") online_label_box.append(online_title) online_label_box.append(online_desc) online_switch = Gtk.Switch() online_switch.set_active(cfg["is_mic_online"]) online_switch.set_halign(Gtk.Align.END) online_switch.set_valign(Gtk.Align.CENTER) row_online.append(online_label_box) row_online.append(online_switch) content.append(row_online) # --- Desktop Voice --- row_desktop = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) desktop_label_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) desktop_label_box.set_hexpand(True) desktop_title = Gtk.Label() self.bind_i18n(desktop_title, "label", "o_Use_Desktop_Audio_Text") desktop_title.set_xalign(0) desktop_title.set_halign(Gtk.Align.START) desktop_desc = Gtk.Label() self.bind_i18n(desktop_desc, "label", "o_Use_Desktop_Audio_Hint") desktop_desc.set_xalign(0) desktop_desc.set_halign(Gtk.Align.START) desktop_desc.set_wrap(True) desktop_desc.add_css_class("dim-label") desktop_label_box.append(desktop_title) desktop_label_box.append(desktop_desc) desktop_switch = Gtk.Switch() desktop_switch.set_active(cfg["use_desktop_voice"]) desktop_switch.set_halign(Gtk.Align.END) desktop_switch.set_valign(Gtk.Align.CENTER) row_desktop.append(desktop_label_box) row_desktop.append(desktop_switch) content.append(row_desktop) # --- Online model --- stt_model_label = Gtk.Label() self.bind_i18n(stt_model_label, "label", "o_Online_STT_Model") stt_model_label.set_xalign(0) content.append(stt_model_label) stt_model_entry = Gtk.Entry() stt_model_entry.set_placeholder_text("openai/gpt-audio-mini") stt_model_entry.set_text(str(cfg["stt_model_online"])) content.append(stt_model_entry) # --- whisper.cpp binary --- whisper_bin_label = Gtk.Label(label=f"whisper.cpp {self('o_Binary_Path')}") whisper_bin_label.set_xalign(0) content.append(whisper_bin_label) whisper_bin_entry = Gtk.Entry() whisper_bin_entry.set_placeholder_text("/home/usr/whisper.cpp/build/bin/whisper-cli") whisper_bin_entry.set_text(str(cfg["whisper_cpp_bin"])) content.append(whisper_bin_entry) # --- whisper.cpp model --- whisper_model_label = Gtk.Label(label=f"whisper.cpp {self('o_Model_Path')}") whisper_model_label.set_xalign(0) content.append(whisper_model_label) whisper_model_entry = Gtk.Entry() whisper_model_entry.set_placeholder_text("/home/usr/.local/share/whisper/ggml-tiny.bin") whisper_model_entry.set_text(str(cfg["whisper_cpp_model"])) content.append(whisper_model_entry) # Link link = Gtk.LinkButton.new_with_label( "https://openrouter.ai/models?fmt=cards&input_modalities=audio", "OpenRouter – STT Models Page" ) link.set_halign(Gtk.Align.CENTER) content.append(link) dialog.add_button(self("o_Cancel"), Gtk.ResponseType.CANCEL) dialog.add_button(self("o_Save"), Gtk.ResponseType.OK) def refresh_sensitive_state(*_): is_online = bool(online_switch.get_active()) stt_model_label.set_sensitive(is_online) stt_model_entry.set_sensitive(is_online) whisper_bin_label.set_sensitive(not is_online) whisper_bin_entry.set_sensitive(not is_online) whisper_model_label.set_sensitive(not is_online) whisper_model_entry.set_sensitive(not is_online) online_switch.connect("notify::active", refresh_sensitive_state) refresh_sensitive_state() def on_response(d, resp): if resp == Gtk.ResponseType.OK: cfg2 = ensure_base_config(self.load_config()) cfg2["is_mic_online"] = bool(online_switch.get_active()) cfg2["use_desktop_voice"] = bool(desktop_switch.get_active()) cfg2["stt_model_online"] = stt_model_entry.get_text().strip() cfg2["whisper_cpp_bin"] = whisper_bin_entry.get_text().strip() cfg2["whisper_cpp_model"] = whisper_model_entry.get_text().strip() self.save_config(cfg2) d.close() dialog.connect("response", on_response) dialog.present() def _show_info_dialog(self, title: str, message: str): dialog = Gtk.Dialog(transient_for=self.win) dialog.set_modal(True) dialog.set_title(title) content = dialog.get_content_area() label = Gtk.Label(label=message) label.set_wrap(True) label.set_xalign(0) content.append(label) dialog.add_button(self("o_OK"), Gtk.ResponseType.OK) dialog.connect("response", lambda d, r: d.destroy()) dialog.present() def _run_provider_command(self, command: str, provider_name: str, action_name: str): cmd = str(command or "").strip() if not cmd: self._show_info_dialog( f"{action_name} {self('o_Error')}", self("o_Provider_Command_No_Command", provider=provider_name) ) return try: # bash ile çalıştır: terminaldeki quote davranışına daha yakın olur p = subprocess.Popen( ["bash", "-lc", cmd], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True, ) def _wait(): try: out, err = p.communicate(timeout=15) rc = p.returncode except subprocess.TimeoutExpired: try: p.kill() except Exception: pass GLib.idle_add( self._show_info_dialog, f"{action_name} {self('o_Timeout')}", self("o_Provider_Command_Timeout", provider=provider_name, command=cmd) ) return except Exception as e: GLib.idle_add( self._show_info_dialog, f"{action_name} {self('o_Error')}", self("o_Provider_Command_Error", provider=provider_name, error=e) ) return out = (out or "").strip() err = (err or "").strip() if rc == 0: msg = self("o_Provider_Command_Success", provider=provider_name, command=cmd) if out: msg += f"\n\nOutput:\n{out[:1200]}" GLib.idle_add(self._show_info_dialog, action_name, msg) else: msg = self("o_Provider_Command_Failed", provider=provider_name, command=cmd, code=rc) if err: msg += f"\n\nError:\n{err[:1200]}" elif out: msg += f"\n\nOutput:\n{out[:1200]}" GLib.idle_add(self._show_info_dialog, f"{action_name} {self('o_Failed')}", msg) threading.Thread(target=_wait, daemon=True).start() except Exception as e: self._show_info_dialog( f"{action_name} {self('o_Error')}", self("o_Provider_Command_Error", provider=provider_name, error=e) ) def stop_selected_provider(self, provider_name: str, provider_cfg: dict): stop_cmd = str((provider_cfg or {}).get("stop_command") or "").strip() self._run_provider_command(stop_cmd, provider_name, self("o_Stop_Provider")) def open_ai_settings_dialog(self): cfg = ensure_base_config(self.load_config()) dialog = Gtk.Dialog(transient_for=self.win) self.bind_i18n(dialog, "title", "o_AI_Settings") dialog.set_modal(True) dialog.set_default_size(520, 380) content = dialog.get_content_area() content.set_spacing(10) content.set_margin_top(10) content.set_margin_bottom(10) content.set_margin_start(10) content.set_margin_end(10) # --- Response Language --- row_response = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) response_label_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) response_label_box.set_hexpand(True) response_title = Gtk.Label() self.bind_i18n(response_title, "label", "o_Force_Language_Text") response_title.set_xalign(0) response_title.set_halign(Gtk.Align.START) response_desc = Gtk.Label() self.bind_i18n(response_desc, "label", "o_Force_Language_Hint") response_desc.set_xalign(0) response_desc.set_halign(Gtk.Align.START) response_desc.set_wrap(True) response_desc.add_css_class("dim-label") response_label_box.append(response_title) response_label_box.append(response_desc) response_switch = Gtk.Switch() response_switch.set_active(cfg["force_ui_language"]) response_switch.set_halign(Gtk.Align.END) response_switch.set_valign(Gtk.Align.CENTER) row_response.append(response_label_box) row_response.append(response_switch) content.append(row_response) # --- ASK FOR WEB SEARCH --- row_ask_web = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) ask_web_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) ask_web_box.set_hexpand(True) ask_web_title = Gtk.Label() self.bind_i18n(ask_web_title, "label", "o_Ask_Web_Search") ask_web_title.set_xalign(0) ask_web_title.set_halign(Gtk.Align.START) ask_web_desc = Gtk.Label() self.bind_i18n(ask_web_desc, "label", "o_Ask_Web_Search_Hint") ask_web_desc.set_xalign(0) ask_web_desc.set_halign(Gtk.Align.START) ask_web_desc.set_wrap(True) ask_web_desc.add_css_class("dim-label") ask_web_box.append(ask_web_title) ask_web_box.append(ask_web_desc) ask_web_switch = Gtk.Switch() ask_web_switch.set_active(bool(cfg.get("ask_for_web_search", True))) ask_web_switch.set_halign(Gtk.Align.END) ask_web_switch.set_valign(Gtk.Align.CENTER) row_ask_web.append(ask_web_box) row_ask_web.append(ask_web_switch) content.append(row_ask_web) # --- AI Providers --- sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) content.append(sep) local_title = Gtk.Label() self.bind_i18n(local_title, "label", "o_AI_Providers") local_title.set_xalign(0) content.append(local_title) local_providers = cfg.get("local_providers", {}) if not isinstance(local_providers, dict): local_providers = {} provider_defaults = { "enabled": False, "base_url": "http://127.0.0.1:11434", "run_startup": "", "stop_command": "", "system_error": "", "temperature": "", "top_p": "", "top_k": "", "repeat_penalty": "", "num_ctx": "", "num_predict": "", "keep_alive": "", "system_prompt": "" } if not local_providers: local_providers = { "ollama": dict(provider_defaults) } provider_names = list(local_providers.keys()) selected_provider_name = provider_names[0] def merged_provider_cfg(provider_name: str) -> dict: out = dict(provider_defaults) raw = local_providers.get(provider_name, {}) if isinstance(raw, dict): out.update(raw) return out local_cfg = merged_provider_cfg(selected_provider_name) provider_select_label = Gtk.Label() self.bind_i18n(provider_select_label, "label", "o_Selected_Provider") provider_select_label.set_xalign(0) content.append(provider_select_label) provider_select_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) provider_select_row.set_hexpand(True) content.append(provider_select_row) provider_string_list = Gtk.StringList.new(provider_names) provider_dropdown = Gtk.DropDown.new(provider_string_list, None) provider_dropdown.set_hexpand(True) provider_dropdown.set_halign(Gtk.Align.FILL) provider_dropdown.set_selected(provider_names.index(selected_provider_name)) provider_select_row.append(provider_dropdown) add_provider_btn = Gtk.Button(label="+") self.bind_i18n(add_provider_btn, "tooltip", "o_Add_Provider") add_provider_btn.set_size_request(34, 34) provider_select_row.append(add_provider_btn) stop_provider_btn = Gtk.Button(label="⏹") self.bind_i18n(stop_provider_btn, "tooltip", "o_Stop_Provider") stop_provider_btn.set_size_request(34, 34) stop_provider_btn.add_css_class("flat") provider_select_row.append(stop_provider_btn) delete_provider_btn = Gtk.Button(label="🗑") self.bind_i18n(delete_provider_btn, "tooltip", "o_Delete_Provider") delete_provider_btn.set_size_request(34, 34) delete_provider_btn.add_css_class("flat") provider_select_row.append(delete_provider_btn) local_provider_name_label = Gtk.Label() self.bind_i18n(local_provider_name_label, "label", "o_Provider_Name") local_provider_name_label.set_xalign(0) content.append(local_provider_name_label) local_provider_name_entry = Gtk.Entry() local_provider_name_entry.set_text(selected_provider_name) local_provider_name_entry.set_placeholder_text("ollama") content.append(local_provider_name_entry) row_local_enabled = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) local_enabled_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) local_enabled_box.set_hexpand(True) local_enabled_title = Gtk.Label() self.bind_i18n(local_enabled_title, "label", "o_Enable_Provider") local_enabled_title.set_xalign(0) local_enabled_title.set_halign(Gtk.Align.START) local_enabled_desc = Gtk.Label() self.bind_i18n(local_enabled_desc, "label", "o_Enable_Provider_Hint") local_enabled_desc.set_xalign(0) local_enabled_desc.set_halign(Gtk.Align.START) local_enabled_desc.set_wrap(True) local_enabled_desc.add_css_class("dim-label") local_enabled_box.append(local_enabled_title) local_enabled_box.append(local_enabled_desc) local_enabled_switch = Gtk.Switch() local_enabled_switch.set_active(bool(local_cfg.get("enabled", False))) local_enabled_switch.set_halign(Gtk.Align.END) local_enabled_switch.set_valign(Gtk.Align.CENTER) row_local_enabled.append(local_enabled_box) row_local_enabled.append(local_enabled_switch) content.append(row_local_enabled) local_base_url_label = Gtk.Label(label="Base URL") local_base_url_label.set_xalign(0) content.append(local_base_url_label) local_base_url_entry = Gtk.Entry() local_base_url_entry.set_text(str(local_cfg.get("base_url", ""))) local_base_url_entry.set_placeholder_text("http://127.0.0.1:11434") content.append(local_base_url_entry) local_run_startup_label = Gtk.Label() self.bind_i18n(local_run_startup_label, "label", "o_Run_Startup") local_run_startup_label.set_xalign(0) content.append(local_run_startup_label) local_run_startup_entry = Gtk.Entry() local_run_startup_entry.set_text(str(local_cfg.get("run_startup", ""))) self.bind_i18n(local_run_startup_entry, "placeholder", "o_Run_Startup_Placeholder") content.append(local_run_startup_entry) local_stop_command_label = Gtk.Label() self.bind_i18n(local_stop_command_label, "label", "o_Stop_Command") local_stop_command_label.set_xalign(0) content.append(local_stop_command_label) local_stop_command_entry = Gtk.Entry() local_stop_command_entry.set_text(str(local_cfg.get("stop_command", ""))) self.bind_i18n(local_stop_command_entry, "placeholder", "o_Stop_Command_Placeholder") content.append(local_stop_command_entry) local_system_error_label = Gtk.Label() self.bind_i18n(local_system_error_label, "label", "o_Custom_Error") local_system_error_label.set_xalign(0) content.append(local_system_error_label) local_system_error_entry = Gtk.Entry() local_system_error_entry.set_text(str(local_cfg.get("system_error", ""))) self.bind_i18n(local_system_error_entry, "placeholder", "o_Custom_Error_Placeholder") content.append(local_system_error_entry) def add_local_param_row(title_text, key, placeholder=""): lab = Gtk.Label(label=title_text) lab.set_xalign(0) content.append(lab) entry = Gtk.Entry() entry.set_text(str(local_cfg.get(key, ""))) if placeholder: entry.set_placeholder_text(placeholder) content.append(entry) return entry local_temperature_entry = add_local_param_row("Temperature", "temperature", "0.7") local_top_p_entry = add_local_param_row("Top P", "top_p", "0.9") local_top_k_entry = add_local_param_row("Top K", "top_k", "40") local_repeat_penalty_entry = add_local_param_row("Repeat Penalty", "repeat_penalty", "1.1") local_num_ctx_entry = add_local_param_row("Num Ctx", "num_ctx", "4096") local_num_predict_entry = add_local_param_row("Num Predict", "num_predict", "512") local_keep_alive_entry = add_local_param_row("Keep Alive", "keep_alive", "5m") local_system_prompt_entry = add_local_param_row(self("o_System_Prompt"), "system_prompt", "") local_link_ollama = Gtk.LinkButton.new_with_label( "https://docs.ollama.com/api/chat", "Ollama – Local API" ) local_link_ollama.set_halign(Gtk.Align.CENTER) content.append(local_link_ollama) local_link_lmstudio = Gtk.LinkButton.new_with_label( "https://lmstudio.ai/docs/developer", "LM Studio – Local API" ) local_link_lmstudio.set_halign(Gtk.Align.CENTER) content.append(local_link_lmstudio) local_link_vllm = Gtk.LinkButton.new_with_label( "https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html", "vLLM – OpenAI-Compatible API" ) local_link_vllm.set_halign(Gtk.Align.CENTER) content.append(local_link_vllm) def get_current_form_data(): return { "enabled": bool(local_enabled_switch.get_active()), "base_url": local_base_url_entry.get_text().strip() or "http://127.0.0.1:11434", "run_startup": local_run_startup_entry.get_text().strip(), "stop_command": local_stop_command_entry.get_text().strip(), "system_error": local_system_error_entry.get_text().strip(), "temperature": local_temperature_entry.get_text().strip(), "top_p": local_top_p_entry.get_text().strip(), "top_k": local_top_k_entry.get_text().strip(), "repeat_penalty": local_repeat_penalty_entry.get_text().strip(), "num_ctx": local_num_ctx_entry.get_text().strip(), "num_predict": local_num_predict_entry.get_text().strip(), "keep_alive": local_keep_alive_entry.get_text().strip(), "system_prompt": local_system_prompt_entry.get_text().strip(), } def set_form_data(provider_name: str): cfg_map = merged_provider_cfg(provider_name) local_provider_name_entry.set_text(provider_name) local_enabled_switch.set_active(bool(cfg_map.get("enabled", False))) local_base_url_entry.set_text(str(cfg_map.get("base_url", ""))) local_run_startup_entry.set_text(str(cfg_map.get("run_startup", ""))) local_run_startup_entry.set_placeholder_text(f"Exp: ollama serve") local_stop_command_entry.set_text(str(cfg_map.get("stop_command", ""))) local_system_error_entry.set_text(str(cfg_map.get("system_error", ""))) local_temperature_entry.set_text(str(cfg_map.get("temperature", ""))) local_top_p_entry.set_text(str(cfg_map.get("top_p", ""))) local_top_k_entry.set_text(str(cfg_map.get("top_k", ""))) local_repeat_penalty_entry.set_text(str(cfg_map.get("repeat_penalty", ""))) local_num_ctx_entry.set_text(str(cfg_map.get("num_ctx", ""))) local_num_predict_entry.set_text(str(cfg_map.get("num_predict", ""))) local_keep_alive_entry.set_text(str(cfg_map.get("keep_alive", ""))) local_system_prompt_entry.set_text(str(cfg_map.get("system_prompt", ""))) def write_local_providers_to_config(): cfg_now = ensure_base_config(self.load_config()) cfg_now["local_providers"] = dict(local_providers) self.save_config(cfg_now) def refresh_provider_dropdown(selected_name: str | None = None): nonlocal provider_names, provider_string_list, selected_provider_name provider_names = list(local_providers.keys()) if not provider_names: local_providers["ollama"] = dict(provider_defaults) provider_names = ["ollama"] provider_string_list = Gtk.StringList.new(provider_names) provider_dropdown.set_model(provider_string_list) if selected_name and selected_name in provider_names: selected_provider_name = selected_name provider_dropdown.set_selected(provider_names.index(selected_name)) else: selected_provider_name = provider_names[0] provider_dropdown.set_selected(0) set_form_data(selected_provider_name) def on_provider_changed(dropdown, _pspec): nonlocal selected_provider_name idx = dropdown.get_selected() if idx == Gtk.INVALID_LIST_POSITION: return old_name = selected_provider_name new_name = provider_names[idx] if old_name and old_name in local_providers: local_providers[old_name] = get_current_form_data() selected_provider_name = new_name set_form_data(new_name) provider_dropdown.connect("notify::selected", on_provider_changed) def on_add_provider_clicked(_btn): nonlocal selected_provider_name if selected_provider_name and selected_provider_name in local_providers: local_providers[selected_provider_name] = get_current_form_data() base_name = "new_provider" new_name = base_name i = 1 while new_name in local_providers: new_name = f"{base_name}_{i}" i += 1 local_providers[new_name] = dict(provider_defaults) write_local_providers_to_config() refresh_provider_dropdown(new_name) local_provider_name_entry.grab_focus() local_provider_name_entry.select_region(0, -1) add_provider_btn.connect("clicked", on_add_provider_clicked) def on_delete_provider_clicked(_btn): nonlocal selected_provider_name if not selected_provider_name or selected_provider_name not in local_providers: return if len(local_providers) <= 1: warn = Gtk.Dialog(transient_for=self.win) warn.set_modal(True) warn.set_title(self("o_Provider_Cant_Be_Deleted")) warn_content = warn.get_content_area() warn_label = Gtk.Label(label=self("o_Provider_Min_One_Required")) warn_label.set_wrap(True) warn_content.append(warn_label) warn.add_button(self("o_OK"), Gtk.ResponseType.OK) warn.connect("response", lambda d, r: d.destroy()) warn.present() return confirm = Gtk.Dialog(transient_for=self.win) confirm.set_modal(True) confirm.set_title(self("o_Delete_Provider")) confirm_content = confirm.get_content_area() confirm_label = Gtk.Label( label=self("o_Delete_Provider_Confirm", provider=selected_provider_name) ) confirm_label.set_wrap(True) confirm_content.append(confirm_label) confirm.add_button(self("o_Cancel"), Gtk.ResponseType.CANCEL) confirm.add_button(self("o_Delete"), Gtk.ResponseType.OK) def on_confirm_response(dlg, response): nonlocal selected_provider_name if response == Gtk.ResponseType.OK: old_name = selected_provider_name if old_name in local_providers: del local_providers[old_name] write_local_providers_to_config() remaining = list(local_providers.keys()) next_name = remaining[0] if remaining else "ollama" refresh_provider_dropdown(next_name) dlg.destroy() confirm.connect("response", on_confirm_response) confirm.present() delete_provider_btn.connect("clicked", on_delete_provider_clicked) def on_stop_provider_clicked(_btn): nonlocal selected_provider_name if not selected_provider_name: return # dialog içindeki henüz kaydedilmemiş form verisini de kullan current_cfg = get_current_form_data() self.stop_selected_provider(selected_provider_name, current_cfg) stop_provider_btn.connect("clicked", on_stop_provider_clicked) dialog.add_button(self("o_Cancel"), Gtk.ResponseType.CANCEL) dialog.add_button(self("o_Save"), Gtk.ResponseType.OK) def on_response(d, resp): if resp == Gtk.ResponseType.OK: cfg2 = ensure_base_config(self.load_config()) cfg2["force_ui_language"] = bool(response_switch.get_active()) cfg2["ask_for_web_search"] = bool(ask_web_switch.get_active()) old_selected_name = selected_provider_name new_provider_name = local_provider_name_entry.get_text().strip() or "ollama" if "local_providers" not in cfg2 or not isinstance(cfg2["local_providers"], dict): cfg2["local_providers"] = {} if old_selected_name in local_providers: local_providers[old_selected_name] = get_current_form_data() if new_provider_name != old_selected_name: if new_provider_name in local_providers and new_provider_name != old_selected_name: suffix = 1 candidate = f"{new_provider_name}_{suffix}" while candidate in local_providers: suffix += 1 candidate = f"{new_provider_name}_{suffix}" new_provider_name = candidate local_providers[new_provider_name] = local_providers.pop(old_selected_name) cfg2["local_providers"] = dict(local_providers) self.save_config(cfg2) d.close() dialog.connect("response", on_response) dialog.present() def open_rag_settings_dialog(self): cfg = ensure_base_config(self.load_config()) rag = cfg["rag_settings"] dialog = Gtk.Dialog(transient_for=self.win) self.bind_i18n(dialog, "title", "o_RAG_Settings") dialog.set_modal(True) dialog.set_default_size(520, 520) content = dialog.get_content_area() content.set_spacing(10) content.set_margin_top(10) content.set_margin_bottom(10) content.set_margin_start(10) content.set_margin_end(10) entries = {} switches = {} def add_int_row(label_key, hint_key, setting_key): row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) row.set_hexpand(True) left = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) left.set_hexpand(True) title = Gtk.Label() self.bind_i18n(title, "label", label_key) title.set_xalign(0) title.set_halign(Gtk.Align.START) hint = Gtk.Label() self.bind_i18n(hint, "label", hint_key) hint.set_xalign(0) hint.set_halign(Gtk.Align.START) hint.set_wrap(True) hint.add_css_class("dim-label") left.append(title) left.append(hint) entry = Gtk.Entry() entry.set_text(str(rag.get(setting_key, ""))) entry.set_width_chars(8) entry.set_max_width_chars(10) entry.set_halign(Gtk.Align.END) row.append(left) row.append(entry) content.append(row) entries[setting_key] = entry def add_bool_row(label_key, hint_key, setting_key): row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) row.set_hexpand(True) left = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) left.set_hexpand(True) title = Gtk.Label() self.bind_i18n(title, "label", label_key) title.set_xalign(0) title.set_halign(Gtk.Align.START) hint = Gtk.Label() self.bind_i18n(hint, "label", hint_key) hint.set_xalign(0) hint.set_halign(Gtk.Align.START) hint.set_wrap(True) hint.add_css_class("dim-label") left.append(title) left.append(hint) sw = Gtk.Switch() sw.set_active(bool(rag.get(setting_key, False))) sw.set_halign(Gtk.Align.END) sw.set_valign(Gtk.Align.CENTER) row.append(left) row.append(sw) content.append(row) switches[setting_key] = sw add_int_row("o_RAG_Recent_Count", "o_RAG_Recent_Count_Hint", "recent_message_count") add_int_row("o_RAG_Retrieved_Count", "o_RAG_Retrieved_Count_Hint", "retrieved_chunk_count") add_int_row("o_RAG_Summary_Every", "o_RAG_Summary_Every_Hint", "summary_update_every") add_int_row("o_RAG_Memory_Chunk_Max", "o_RAG_Memory_Chunk_Max_Hint", "memory_chunk_max_chars") add_int_row("o_RAG_Summary_Max", "o_RAG_Summary_Max_Hint", "summary_max_chars") add_int_row("o_RAG_Code_Context_Max", "o_RAG_Code_Context_Max_Hint", "code_context_max_chars") sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) content.append(sep) add_bool_row("o_RAG_Use_Summary", "o_RAG_Use_Summary_Hint", "use_summary") add_bool_row("o_RAG_Use_Recent", "o_RAG_Use_Recent_Hint", "use_recent_messages") add_bool_row("o_RAG_Use_Retrieval", "o_RAG_Use_Retrieval_Hint", "use_retrieval") add_bool_row("o_RAG_Use_Code_Context", "o_RAG_Use_Code_Context_Hint", "use_code_context") add_bool_row("o_RAG_Recent_Attachments", "o_RAG_Recent_Attachments_Hint", "include_recent_attachments") dialog.add_button(self("o_Cancel"), Gtk.ResponseType.CANCEL) dialog.add_button(self("o_Save"), Gtk.ResponseType.OK) def safe_int(entry, default, min_v, max_v): try: val = int(entry.get_text().strip()) except Exception: val = default return max(min_v, min(max_v, val)) def on_response(d, resp): if resp == Gtk.ResponseType.OK: cfg2 = ensure_base_config(self.load_config()) rag2 = dict(cfg2.get("rag_settings", {})) rag2["recent_message_count"] = safe_int(entries["recent_message_count"], 10, 0, 50) rag2["retrieved_chunk_count"] = safe_int(entries["retrieved_chunk_count"], 5, 0, 50) rag2["summary_update_every"] = safe_int(entries["summary_update_every"], 20, 1, 200) rag2["memory_chunk_max_chars"] = safe_int(entries["memory_chunk_max_chars"], 1200, 100, 100_000) rag2["summary_max_chars"] = safe_int(entries["summary_max_chars"], 6000, 100, 100_000) rag2["code_context_max_chars"] = safe_int(entries["code_context_max_chars"], 8000, 100, 100_000) for key, sw in switches.items(): rag2[key] = bool(sw.get_active()) cfg2["rag_settings"] = rag2 self.save_config(cfg2) d.close() dialog.connect("response", on_response) dialog.present() def open_language_dialog(self): dialog = Gtk.Dialog(transient_for=self.win) self.bind_i18n(dialog, "title", "o_Language") dialog.set_modal(True) content = dialog.get_content_area() content.set_spacing(8) content.set_margin_top(10) content.set_margin_bottom(10) content.set_margin_start(10) content.set_margin_end(10) current_lang = self.get_ui_language() langs = self.get_available_ui_languages() label = Gtk.Label() self.bind_i18n(label, "label", "o_Language") label.set_xalign(0) content.append(label) combo = Gtk.ComboBoxText() for lang in langs: combo.append(lang, lang) combo.set_active_id(current_lang) content.append(combo) dialog.add_button(self("o_Cancel"), Gtk.ResponseType.CANCEL) dialog.add_button(self("o_Save"), Gtk.ResponseType.OK) def on_response(d, resp): if resp == Gtk.ResponseType.OK: chosen = combo.get_active_id() if chosen: self.set_ui_language(chosen) self.refresh_ui_texts() d.close() dialog.connect("response", on_response) dialog.present() def _get_chat_bg_image_path(self) -> str: cfg = ensure_base_config(self.load_config()) return str(cfg["chat_bg_image"]).strip() def _save_chat_bg_image_path(self, path: str): cfg = self.load_config() cfg["chat_bg_image"] = str(path or "").strip() self.save_config(cfg) def _reset_chat_bg_image(self): cfg = self.load_config() cfg["chat_bg_image"] = "" self.save_config(cfg) def refresh_visual_theme_only(self): self.apply_css() cfg = ensure_base_config(self.load_config()) self.apply_theme(cfg["dark_mode"]) def refresh_entire_ui_theme(self): self.refresh_visual_theme_only() self.refresh_ui_texts() self.load_chat() self.load_chat_list() self.load_models_list() self.refresh_attachments_preview() GLib.idle_add(self.scroll_to_bottom, True) def open_personalization_dialog(self): cfg = ensure_base_config(self.load_config()) is_dark = cfg["dark_mode"] mode_name = "Dark Mode" if is_dark else "Light Mode" dialog = Gtk.Dialog(title=f"{self('o_Personalization')} ({mode_name})",transient_for=self.win) dialog.set_modal(True) dialog.set_default_size(460, 340) content = dialog.get_content_area() content.set_spacing(8) content.set_margin_top(10) content.set_margin_bottom(10) content.set_margin_start(10) content.set_margin_end(10) colors = self._get_theme_colors() rows = {} def on_pick_bg(*_): def on_path_selected(path): if path: bg_path_entry.set_text(path) self.open_single_file_dialog_portable( title=self("o_Select_Image"), image_only=True, callback=on_path_selected ) def on_clear_bg(*_): bg_path_entry.set_text("") def add_row(label_text, key): row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) lab = Gtk.Label(label=label_text) lab.set_xalign(0) lab.set_hexpand(True) entry = Gtk.Entry() entry.set_text(colors[key]) entry.set_placeholder_text("#rrggbb") row.append(lab) row.append(entry) content.append(row) rows[key] = entry hint = Gtk.Label() hint.set_label(self("o_Color_Edit_Hint").format(mode_name=mode_name)) hint.set_xalign(0) hint.set_wrap(True) hint.add_css_class("dim-label") content.append(hint) 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") add_row(self("o_Local_Model_Color"), "local_model_highlight") # --- SHOW USAGE SWITCH --- row_usage = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) usage_label_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) usage_label_box.set_hexpand(True) usage_title = Gtk.Label() self.bind_i18n(usage_title, "label", "o_Token_Usage") usage_title.set_xalign(0) usage_title.set_halign(Gtk.Align.START) usage_label_box.append(usage_title) usage_switch = Gtk.Switch() usage_switch.set_active(cfg["show_usage"]) usage_switch.set_halign(Gtk.Align.END) usage_switch.set_valign(Gtk.Align.CENTER) row_usage.append(usage_label_box) row_usage.append(usage_switch) content.append(row_usage) # --- SHOW TOKEN VALUE SWITCH --- row_show_token_value = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) show_token_value_label_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) show_token_value_label_box.set_hexpand(True) show_token_value_title = Gtk.Label() self.bind_i18n(show_token_value_title, "label", "o_Use_Token_Value") show_token_value_title.set_xalign(0) show_token_value_title.set_halign(Gtk.Align.START) show_token_value_label_box.append(show_token_value_title) show_token_value_switch = Gtk.Switch() show_token_value_switch.set_active(bool(cfg.get("show_token_value", False))) show_token_value_switch.set_halign(Gtk.Align.END) show_token_value_switch.set_valign(Gtk.Align.CENTER) row_show_token_value.append(show_token_value_label_box) row_show_token_value.append(show_token_value_switch) content.append(row_show_token_value) # --- TOKEN VALUE ENTRY --- row_token_value = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) token_value_left = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) token_value_left.set_hexpand(True) token_value_label = Gtk.Label() self.bind_i18n(token_value_label, "label", "o_Token_Value") token_value_label.set_xalign(0) token_value_label.set_halign(Gtk.Align.START) token_value_hint = Gtk.Label() self.bind_i18n(token_value_hint, "label", "o_Token_Value_Hint") token_value_hint.set_xalign(0) token_value_hint.set_halign(Gtk.Align.START) token_value_hint.set_wrap(True) token_value_hint.add_css_class("dim-label") token_value_left.append(token_value_label) token_value_left.append(token_value_hint) token_value_entry = Gtk.Entry() token_value_entry.set_text(str(cfg.get("token_value", "2.0"))) token_value_entry.set_placeholder_text("2.0") token_value_entry.set_width_chars(8) token_value_entry.set_max_width_chars(10) token_value_entry.set_halign(Gtk.Align.END) row_token_value.append(token_value_left) row_token_value.append(token_value_entry) content.append(row_token_value) def refresh_token_value_sensitive(*_): active = bool(show_token_value_switch.get_active()) token_value_label.set_sensitive(active) token_value_hint.set_sensitive(active) token_value_entry.set_sensitive(active) row_token_value.set_sensitive(active) show_token_value_switch.connect("notify::active", refresh_token_value_sensitive) refresh_token_value_sensitive() bg_path_label = Gtk.Label() self.bind_i18n(bg_path_label, "label", "o_Background_Hint") bg_path_label.set_xalign(0) content.append(bg_path_label) bg_path_controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) bg_path_entry = Gtk.Entry() bg_path_entry.set_hexpand(True) 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=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) bg_path_controls.append(bg_path_entry) bg_path_controls.append(bg_pick_btn) bg_path_controls.append(bg_clear_btn) content.append(bg_path_controls) reset_btn = Gtk.Button(label=f"{self('o_Reset_Default')}") content.append(reset_btn) dialog.add_button(self("o_Cancel"), Gtk.ResponseType.CANCEL) dialog.add_button(self("o_Save"), Gtk.ResponseType.OK) def valid_hex(s: str) -> bool: s = (s or "").strip() if len(s) != 7 or not s.startswith("#"): return False try: int(s[1:], 16) return True except Exception: return False def on_reset(*_): defaults = self._get_default_theme_colors(is_dark) for key, entry in rows.items(): entry.set_text(defaults[key]) bg_path_entry.set_text("") usage_switch.set_active(True) show_token_value_switch.set_active(False) token_value_entry.set_text("2.0") refresh_token_value_sensitive() reset_btn.connect("clicked", on_reset) def on_response(d, resp): if resp == Gtk.ResponseType.OK: cfg2 = self.load_config() cfg2["show_usage"] = bool(usage_switch.get_active()) cfg2["show_token_value"] = bool(show_token_value_switch.get_active()) val = token_value_entry.get_text().strip() cfg2["token_value"] = val if val else "2.0" self.save_config(cfg2) new_colors = {} for key, entry in rows.items(): val = entry.get_text().strip() if not valid_hex(val): return new_colors[key] = val bg_path = bg_path_entry.get_text().strip() if bg_path: try: p = Path(bg_path).expanduser().resolve() bg_path = str(p) except Exception: bg_path = "" self._save_theme_colors(new_colors) self._save_chat_bg_image_path(bg_path) self.refresh_entire_ui_theme() d.close() dialog.connect("response", on_response) dialog.present() def open_style_dialog(self): dialog = Gtk.Dialog(transient_for=self.win) self.bind_i18n(dialog, "title", "o_Response_Style") dialog.set_modal(True) content = dialog.get_content_area() content.set_spacing(10) content.set_margin_top(10) content.set_margin_bottom(10) content.set_margin_start(10) content.set_margin_end(10) label = Gtk.Label() self.bind_i18n(label, "label", "o_Response_Style") label.set_xalign(0) content.append(label) entry = Gtk.Entry() self.bind_i18n(entry, "placeholder", "o_Response_Style_Placeholder") cfg = ensure_base_config(self.load_config()) entry.set_text(cfg["response_style"]) content.append(entry) dialog.add_button(self("o_Cancel"), Gtk.ResponseType.CANCEL) dialog.add_button(self("o_Save"), Gtk.ResponseType.OK) # Enter → Kaydet entry.connect("activate", lambda *_: dialog.response(Gtk.ResponseType.OK)) key_controller = Gtk.EventControllerKey() def on_key_pressed(_controller, keyval, _keycode, state): ctrl = bool(state & Gdk.ModifierType.CONTROL_MASK) display = Gdk.Display.get_default() clipboard = display.get_clipboard() if display else None # Ctrl+A → Hepsini seç if ctrl and keyval in (Gdk.KEY_a, Gdk.KEY_A): entry.select_region(0, -1) return True # Ctrl+V → Yapıştır if ctrl and keyval in (Gdk.KEY_v, Gdk.KEY_V): if not clipboard: return False def on_text(cb, res): try: text = cb.read_text_finish(res) or "" except Exception: text = "" if not text: return sel = entry.get_selection_bounds() if sel: s, e = sel entry.delete_text(s, e) pos = entry.get_position() entry.insert_text(text, pos) entry.set_position(pos + len(text)) clipboard.read_text_async(None, on_text) return True # Ctrl+X → Kes if ctrl and keyval in (Gdk.KEY_x, Gdk.KEY_X): if not clipboard: return True text = entry.get_text() or "" if not text: return True sel = entry.get_selection_bounds() if sel: s, e = sel cut = text[s:e] clipboard.set(cut) entry.delete_text(s, e) else: clipboard.set(text) entry.set_text("") return True # Enter → Kaydet if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): dialog.response(Gtk.ResponseType.OK) return True return False key_controller.connect("key-pressed", on_key_pressed) entry.add_controller(key_controller) def on_response(d, resp): if resp == Gtk.ResponseType.OK: cfg2 = self.load_config() cfg2["response_style"] = entry.get_text().strip() self.save_config(cfg2) d.close() dialog.connect("response", on_response) dialog.show() # Fokus ver ama seçim yapma def focus_entry(): entry.grab_focus() entry.set_position(-1) # cursor sona gider entry.select_region(0, 0) # seçim yok return False GLib.idle_add(focus_entry) def open_key_dialog(self, _button=None): cfg = ensure_base_config(self.load_config()) dialog = Gtk.Dialog() dialog.set_transient_for(self.win) dialog.set_modal(True) self.bind_i18n(dialog, "title", "o_OpenRouter-Tavily_API_Key") content = dialog.get_content_area() content.set_spacing(10) content.set_margin_top(10) content.set_margin_bottom(10) content.set_margin_start(10) content.set_margin_end(10) # OpenRouter API label = Gtk.Label() self.bind_i18n(label, "label", "o_OpenRouter_API_Key") label.set_xalign(0) content.append(label) openrouter_entry = Gtk.Entry() self.bind_i18n(openrouter_entry, "placeholder", "o_OpenRouter_Placeholder") openrouter_entry.set_visibility(False) openrouter_entry.set_text(str(cfg.get("open_router_key", ""))) content.append(openrouter_entry) # Tavily API label = Gtk.Label() self.bind_i18n(label, "label", "o_Tavily_API_Key") label.set_xalign(0) content.append(label) tavily_entry = Gtk.Entry() self.bind_i18n(tavily_entry, "placeholder", "o_Tavily_Placeholder") tavily_entry.set_visibility(False) tavily_entry.set_text(str(cfg.get("tavily_api_key", ""))) content.append(tavily_entry) # Ctrl+V ile yapıştırmayı garanti et key_controller = Gtk.EventControllerKey() dialog.add_button(self("o_Cancel"), Gtk.ResponseType.CANCEL) dialog.add_button(self("o_Save"), Gtk.ResponseType.OK) def on_response(dialog, response): if response == Gtk.ResponseType.OK: cfg = ensure_base_config(self.load_config()) cfg["open_router_key"] = openrouter_entry.get_text().strip() cfg["tavily_api_key"] = tavily_entry.get_text().strip() self.save_config(cfg) dialog.destroy() dialog.connect("response", on_response) dialog.present() def on_key_pressed(controller, keyval, keycode, state): ctrl = bool(state & Gdk.ModifierType.CONTROL_MASK) # hem v hem V yakala if ctrl and keyval in (Gdk.KEY_v, Gdk.KEY_V): display = Gdk.Display.get_default() if not display: return False clipboard = display.get_clipboard() def on_text(cb, res): try: text = cb.read_text_finish(res) or "" except Exception: text = "" if text: entry.set_text(text.strip()) clipboard.read_text_async(None, on_text) return True return False key_controller.connect("key-pressed", on_key_pressed) entry.add_controller(key_controller) def open_settings_menu(self, button): pop = Gtk.Popover() pop.set_parent(button) pop.set_position(Gtk.PositionType.TOP) pop.set_has_arrow(True) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) box.set_margin_top(8) box.set_margin_bottom(8) box.set_margin_start(8) box.set_margin_end(8) api_btn = Gtk.Button(label=self('o_OpenRouter-Tavily_API_Key')) api_btn.set_halign(Gtk.Align.FILL) api_btn.connect("clicked", lambda *_: (pop.popdown(), self.open_key_dialog(None))) box.append(api_btn) style_btn = Gtk.Button(label=self('o_Response_Style')) style_btn.connect("clicked", lambda *_: (pop.popdown(), self.open_style_dialog())) box.append(style_btn) personalization_btn = Gtk.Button(label=self('o_Personalization')) personalization_btn.connect("clicked", lambda *_: (pop.popdown(), self.open_personalization_dialog())) box.append(personalization_btn) lang_btn = Gtk.Button(label=self('o_Language')) lang_btn.connect("clicked", lambda *_: (pop.popdown(), self.open_language_dialog())) box.append(lang_btn) rag_settings_btn = Gtk.Button(label=self("o_RAG_Settings")) rag_settings_btn.connect("clicked", lambda *_: (pop.popdown(), self.open_rag_settings_dialog())) box.append(rag_settings_btn) ai_settings_btn = Gtk.Button(label=self('o_AI_Settings')) ai_settings_btn.connect("clicked", lambda *_: (pop.popdown(), self.open_ai_settings_dialog())) box.append(ai_settings_btn) stt_settings_btn = Gtk.Button(label=self('o_STT_Settings')) stt_settings_btn.connect("clicked", lambda *_: (pop.popdown(), self.open_stt_settings_dialog())) box.append(stt_settings_btn) pop.set_child(box) pop.popup() # ---------------- CHAT LIST ---------------- def load_chat_list(self): self.streaming_label = None while True: row = self.chat_list.get_first_child() if not row: break self.chat_list.remove(row) cfg = ensure_base_config(self.load_config()) pinned = cfg["pinned_chats"] files = list(CHAT_DIR.glob("*.json")) def mtime(p): try: return p.stat().st_mtime except Exception: return 0.0 def sort_key(p): return (0 if p.name in pinned else 1, -mtime(p), p.name.lower()) for file in sorted(files, key=sort_key): row = Gtk.ListBoxRow() row.chat_path = file box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) box.set_hexpand(True) if file.name in pinned: pin_label = Gtk.Label(label="📌") pin_label.set_xalign(0) box.append(pin_label) label = Gtk.Label(label=file.stem) label.set_xalign(0) label.set_hexpand(True) label.set_halign(Gtk.Align.FILL) box.append(label) more_btn = Gtk.Button(label="⋯") self.bind_i18n(more_btn, "tooltip", "o_Options") more_btn.set_size_request(30, 30) more_btn.add_css_class("flat") box.append(more_btn) popover = Gtk.Popover() popover.set_autohide(True) popover.set_has_arrow(True) popover.set_position(Gtk.PositionType.BOTTOM) popover.set_parent(more_btn) pop_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) pop_box.set_margin_top(8) pop_box.set_margin_bottom(8) pop_box.set_margin_start(8) pop_box.set_margin_end(8) # Pin toggle is_pinned = file.name in pinned pin_item = Gtk.Button(label=(f"{self('o_Unpin')}" if is_pinned else f"{self('o_Pin')}")) pin_item.set_halign(Gtk.Align.FILL) def _toggle_pin(_btn, f=file, p=popover): p.popdown() self.toggle_pin_chat(f) pin_item.connect("clicked", _toggle_pin) pop_box.append(pin_item) # Rename rename_item = Gtk.Button(label=f"{self('o_Rename')}") rename_item.set_halign(Gtk.Align.FILL) rename_item.connect( "clicked", lambda *_, f=file, p=popover, anchor=more_btn: self.open_rename_popover(anchor, f, p) ) pop_box.append(rename_item) # Delete del_item = Gtk.Button(label=f"{self('o_Delete_Chat')}") del_item.set_halign(Gtk.Align.FILL) del_item.connect("clicked", lambda *_, f=file, p=popover: (p.popdown(), self.delete_chat(None, f))) pop_box.append(del_item) popover.set_child(pop_box) more_btn.connect("clicked", lambda *_, p=popover: p.popup()) row.set_child(box) self.chat_list.append(row) if file == self.current_chat: GLib.idle_add(self.chat_list.select_row, row) # ---------------- CHAT DELETE ---------------- def delete_chat(self, button, file_path): dialog = Gtk.Dialog() dialog.set_transient_for(self.win) self.bind_i18n(dialog, "title", "o_Delete_Chat") dialog.set_modal(True) content = dialog.get_content_area() label = Gtk.Label() self.bind_i18n(label, "label", "o_Delete_Chat_Confirm") label.set_wrap(True) content.append(label) dialog.add_button(self("o_Cancel"), Gtk.ResponseType.CANCEL) dialog.add_button(self("o_Delete"), Gtk.ResponseType.OK) def on_response(dialog, response): if response == Gtk.ResponseType.OK: if file_path.exists(): file_path.unlink() cfg = ensure_base_config(self.load_config()) pinned = cfg["pinned_chats"] if file_path.name in pinned: cfg["pinned_chats"] = [x for x in pinned if x != file_path.name] # chat_models cleanup chat_models = cfg["chat_models"] if file_path.name in chat_models: del chat_models[file_path.name] cfg["chat_models"] = chat_models chat_rag_switch = cfg.setdefault("chat_rag_switch", {}) if file_path.name in chat_rag_switch: del chat_rag_switch[file_path.name] cfg["chat_rag_switch"] = chat_rag_switch if cfg["last_chat"] == file_path.name: cfg["last_chat"] = "default.json" self.save_config(cfg) if self.current_chat == file_path: chats = list(CHAT_DIR.glob("*.json")) if chats: self.current_chat = chats[0] else: DEFAULT_CHAT.write_text( json.dumps({ "summary": "", "messages": [], "code_context": {}, "memory_chunks": [] }, ensure_ascii=False, indent=2), encoding="utf-8" ) self.current_chat = DEFAULT_CHAT # active model for new current chat self.active_model = self.get_chat_model(self.current_chat) self.load_chat_list() self.load_chat() self.load_models_list() dialog.destroy() dialog.connect("response", on_response) dialog.present() # ---------------- CHAT VISIBILITY / SIDEBAR ---------------- def apply_chats_visibility(self): if not getattr(self, "sidebar_expanded", True): self.chat_list.set_visible(False) return self.chat_list.set_visible(self.chats_list_open) self.chats_toggle_btn.set_label(f"{self('o_Chats')} ▾" if self.chats_list_open else f"{self('o_Chats')} ▸") def toggle_chats_list(self, *_): self.chats_list_open = not self.chats_list_open self.apply_chats_visibility() def toggle_sidebar(self, button): if self.sidebar_expanded: self.sidebar.set_size_request(60, -1) self.sidebar_expanded = False self.sidebar_scroll.set_visible(False) self.new_btn_sidebar.set_visible(True) self.sidebar_spacer.set_visible(True) self.apply_chats_visibility() self.apply_models_visibility() else: self.sidebar.set_size_request(250, -1) self.sidebar.set_hexpand(False) self.sidebar.set_vexpand(True) self.sidebar_expanded = True self.sidebar_scroll.set_visible(True) self.new_btn_sidebar.set_visible(False) self.sidebar_spacer.set_visible(False) self.apply_chats_visibility() self.apply_models_visibility() # ----------------COPY / REGENERATE -------------------- def find_prev_user_index(self, messages: list, from_idx: int): j = from_idx - 1 while j >= 0: if messages[j].get("role") == "user": return j j -= 1 return None def build_regen_prompt_for_bot(self, question: str, answer: str) -> str: return ( "Aşağıdaki soru ve verilen cevaba benzer bir sonuç üret.\n" "Aynı yaklaşımı ve içeriği koru, ama metni birebir kopyalama; yeniden yaz " "ve mümkünse küçük iyileştirmeler yap.\n\n" "[SORU]\n" f"{question.strip()}\n\n" "[ÖNCEKİ CEVAP]\n" f"{answer.strip()}\n" ) def copy_message_by_index(self, idx: int): try: chat_data = self.load_chat_data_ui() messages = chat_data["messages"] except Exception: return if not (0 <= idx < len(messages)): return content = self.render_message_text(messages[idx]) if not content: return inner = self.extract_copy_block(content) self.copy_to_clipboard(inner if inner is not None else content) def download_copy_block_content(self, content: str, suggested_name: str = "content.txt"): content = str(content or "") def safe_name(name: str): name = str(name or "").strip() or "content.txt" for ch in ["/", "\\", "\n", "\r", "\t"]: name = name.replace(ch, "_") if "." not in Path(name).name: name += ".txt" return name suggested_name = safe_name(suggested_name) def do_write(dest_path): if not dest_path: return try: Path(dest_path).write_text(content, encoding="utf-8") self._show_info_dialog( self("o_Download"), self("o_File_Saved") ) except Exception as e: self._show_info_dialog(self("o_Error"), str(e)) try: dialog = Gtk.FileDialog() dialog.set_initial_name(suggested_name) def on_save(dlg, res): try: gfile = dlg.save_finish(res) dest = gfile.get_path() if gfile else None except Exception: return if dest: do_write(dest) dialog.save(self.win, None, on_save) return except Exception: pass dest = Path.home() / "Downloads" / suggested_name do_write(str(dest)) def download_generated_file(self, src_path: str): src = Path(src_path).expanduser() if not src.exists() or not src.is_file(): self._show_info_dialog( self("o_Error"), self("o_File_Not_Found") if self("o_File_Not_Found") != "o_File_Not_Found" else "File not found." ) return def do_copy(dest_path): if not dest_path: return try: shutil.copy2(src, dest_path) self._show_info_dialog( self("o_Download"), self("o_File_Saved") ) except Exception as e: self._show_info_dialog( self("o_Error"), str(e) ) # Modern GTK save dialog try: dialog = Gtk.FileDialog() dialog.set_initial_name(src.name) def on_save(dlg, res): try: gfile = dlg.save_finish(res) dest = gfile.get_path() if gfile else None except Exception: return if dest: do_copy(dest) dialog.save(self.win, None, on_save) return except Exception: pass # fallback: Downloads içine kopyala dest = Path.home() / "Downloads" / src.name do_copy(str(dest)) def regenerate_by_index(self, idx: int): # 1) Chat'i oku try: chat_data = self.load_chat_data_ui() messages = chat_data["messages"] except Exception: return if not (0 <= idx < len(messages)): return if getattr(self, "is_generating", False): return role = (messages[idx].get("role") or "").strip() content = self.render_message_text(messages[idx]).strip() if not content: return # 2) regen_text üret regen_text = None q_idx = None if role == "user": # user seçiliyse: aynısını tekrar sor regen_text = content else: # bot seçiliyse: bağlı user sorusunu bul + bot cevabı ile benzer üret q_idx = self.find_prev_user_index(messages, idx) question = (messages[q_idx].get("content") or "").strip() if q_idx is not None else "" # bot cevabı copy bloğuysa içeriğini al (daha iyi prompt) ans_inner = self.extract_copy_block(content) answer = ans_inner if ans_inner is not None else content if question: # build_regen_prompt_for_bot senin mevcut fonksiyonun (eski özellik) regen_text = self.build_regen_prompt_for_bot(question, answer) else: # user bulunamadıysa sadece cevaba benzer üret regen_text = ( "Aşağıdaki cevaba benzer bir sonuç üret. Metni birebir kopyalama; yeniden yaz ve iyileştir.\n\n" f"[ÖNCEKİ CEVAP]\n{answer}\n" ) if not regen_text: return # 3) Referans zinciri: seçilen mesajın used_refs'lerini de dahil et # (böylece içinde referans olan mesaj regenerate edilirken foto/ref kaybolmaz) ref_set = set() ref_set.add(idx) used = messages[idx].get("used_refs") or [] if isinstance(used, list): for j in used: if isinstance(j, int) and 0 <= j < len(messages): ref_set |= self.expand_ref_chain(messages, j) # bot seçildiyse bağlı user'ı da dahil et + onun zinciri if role != "user" and q_idx is not None: ref_set.add(q_idx) used_q = messages[q_idx].get("used_refs") or [] if isinstance(used_q, list): for j in used_q: if isinstance(j, int) and 0 <= j < len(messages): ref_set |= self.expand_ref_chain(messages, j) selected_arg = ",".join(str(x) for x in sorted(ref_set)) # 4) Chat sonuna yeni user mesajı ekle (regen flag + referans meta + preview) new_user = { "role": "user", "content": regen_text, "regen": True, # regenerate rengi/işareti "used_refs": sorted(ref_set), # AI'ya giden referans setiyle uyumlu meta } # UI'da karışmaması için: seçilen balon + (bot ise bağlı user) ayrı gruplar groups = [] def _collect_group_for_index(ridx: int): if not (0 <= ridx < len(messages)): return None rmsg = messages[ridx] items = [] # referans mesajının kendi önceki preview'ları (yeni format) rg = rmsg.get("refs_groups") if isinstance(rg, list) and rg: for g in rg: its = g.get("items") if isinstance(g, dict) else None if isinstance(its, list): for it in its: if isinstance(it, dict) and it.get("text"): items.append({"role": it.get("role", ""), "text": it.get("text", "")}) else: # eski format kalmış olabilir rp = rmsg.get("refs_preview") if isinstance(rp, list) and rp: for it in rp: if isinstance(it, dict) and it.get("text"): items.append({"role": it.get("role", ""), "text": it.get("text", "")}) # en sona referans mesajın kendisini koy (metin + image varsa pack_preview_item ekler) try: items.append(self.pack_preview_item(rmsg)) except Exception: # pack_preview_item yoksa yine de minimum göster rrole = (rmsg.get("role") or "").strip() rtext = (rmsg.get("content") or "").strip() if not rtext and rmsg.get("image"): rtext = "[image]" items.append({"role": rrole, "text": rtext}) return {"items": items} g1 = _collect_group_for_index(idx) if g1 is not None: groups.append(g1) if role != "user" and q_idx is not None: g2 = _collect_group_for_index(q_idx) if g2 is not None: groups.append(g2) new_user["refs_groups"] = groups messages.append(new_user) messages.append({ "role": "bot", "content": "", "streaming": True }) self._streaming_bot_idx = len(messages) - 1 try: chat_data["messages"] = messages self.save_chat_data_ui(chat_data) except Exception: return # 5) UI: seçim temizle + typing indicator self.selected_indexes.clear() self.selection_mode = False self.update_selection_label() self.auto_scroll_enabled = True self.load_chat() self.show_typing_indicator() self.ai_stop_requested = False self.set_generating_state(True) # 6) AI çağır: zincir referanslarla birlikte chat_path_for_ai = self.current_chat threading.Thread( target=self.call_ai, args=(selected_arg, chat_path_for_ai), daemon=True ).start() def expand_ref_chain(self, messages: list, idx: int, seen=None) -> set[int]: if seen is None: seen = set() if idx in seen: return seen seen.add(idx) msg = messages[idx] if 0 <= idx < len(messages) else None if not isinstance(msg, dict): return seen used = msg.get("used_refs") or [] if isinstance(used, list): for j in used: if isinstance(j, int) and 0 <= j < len(messages): self.expand_ref_chain(messages, j, seen) return seen def pack_preview_item(self, rmsg: dict) -> dict: rrole = (rmsg.get("role") or "").strip() rtext = (rmsg.get("content") or "").strip() has_img = bool( rmsg.get("image") or (isinstance(rmsg.get("images"), list) and rmsg.get("images")) ) if not rtext and has_img: rtext = "[image]" elif has_img: # metin + image birlikteyse anlaşılır olsun rtext = rtext + " + [image]" return {"role": rrole, "text": rtext} # ---------------- CHAT CREATE / SWITCH ---------------- def create_chat(self, button): i = 1 while True: name = f"chat_{i}.json" path = CHAT_DIR / name if not path.exists(): break i += 1 #Buradan itibaren rag yaziyoruz path.write_text( json.dumps({ "summary": "", "messages": [], "code_context": {}, "memory_chunks": [] }, ensure_ascii=False, indent=2), encoding="utf-8" ) self.current_chat = path self.loaded_message_start = None self._loading_older_messages = False cfg = ensure_base_config(self.load_config()) cfg["last_chat"] = path.name models = self._ensure_min_one_model(cfg) cfg["chat_models"][path.name] = { "id": str(models[0]["id"]), "local": bool(models[0].get("local", False)) } cfg.setdefault("chat_rag_switch", {}) cfg["chat_rag_switch"][path.name] = True self.save_config(cfg) self.active_model = self.get_chat_model(self.current_chat) self.load_chat_list() self.load_chat() self.load_models_list() self.refresh_context_mode_switch() if not self.sidebar_expanded: self.toggle_sidebar(None) self.auto_focus_enabled = True GLib.idle_add(self.force_focus_entry) def switch_chat(self, listbox, row): if not row: return chat_path = getattr(row, "chat_path", None) if not chat_path: return self.clear_closed_chat_memory() self.current_chat = Path(chat_path) self.loaded_message_start = None self._loading_older_messages = False self.selected_indexes.clear() self.selection_mode = False self.update_selection_label() cfg = self.load_config() cfg["last_chat"] = self.current_chat.name self.save_config(cfg) self.selected_indexes.clear() self.selection_mode = False self.selection_label.set_text("") self.active_model = self.get_chat_model(self.current_chat) self.load_chat() self.load_models_list() self.refresh_context_mode_switch() self.refresh_current_generating_ui() self.auto_focus_enabled = True GLib.idle_add(self.force_focus_entry) # ---------------- CHAT LOAD / SELECTION ---------------- def clear_selection(self, *_): self.auto_scroll_enabled = False old_scroll = None try: adj = self.scroll.get_vadjustment() if adj: old_scroll = adj.get_value() except Exception: pass self.selected_indexes.clear() self.selection_mode = False self.update_selection_label() self.load_chat() self.restore_scroll_position_later(old_scroll) def show_empty_chat_suggestions(self): outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) outer.set_hexpand(True) outer.set_vexpand(True) outer.set_halign(Gtk.Align.CENTER) outer.set_valign(Gtk.Align.CENTER) outer.add_css_class("suggestion-wrap") title = Gtk.Label(label=self("o_Suggestions_Title")) title.set_halign(Gtk.Align.CENTER) title.add_css_class("dim-label") outer.append(title) suggestions_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) suggestions_box.set_halign(Gtk.Align.CENTER) keys = [ "o_Suggestion_1", "o_Suggestion_2", "o_Suggestion_3", "o_Suggestion_4", "o_Suggestion_5", ] for key in keys: text = self(key).strip() # Çeviri yoksa key'in kendisi döner; onu göstermeyelim if not text or text == key: continue btn = Gtk.Button(label=text) btn.add_css_class("flat") btn.add_css_class("suggestion-bubble") btn.set_halign(Gtk.Align.CENTER) self._consume_click(btn) def _on_suggestion_clicked(_btn, suggestion=text): buf = self.textview.get_buffer() buf.set_text(suggestion) self.textview.grab_focus() btn.connect("clicked", _on_suggestion_clicked) suggestions_box.append(btn) outer.append(suggestions_box) self.chat_box.append(outer) def load_chat(self): alloc = self.chat_box.get_allocation() chat_width = alloc.width if alloc.width > 0 else 800 # fallback bubble_max_px = int(chat_width * 0.7) # %70 Bot bubbles max width # Typing indicator varsa, önce onu çıkarıp kenarda tutacağız (silmek yok) kept_typing_row = None if getattr(self, "typing_active", False) and hasattr(self, "typing_row"): try: kept_typing_row = self.typing_row # container’dan çıkar ama objeyi öldürme if kept_typing_row.get_parent() is self.chat_box: self.chat_box.remove(kept_typing_row) except Exception: kept_typing_row = None # normal temizleme (typing hariç her şeyi kaldır) while True: child = self.chat_box.get_first_child() if not child: break self.chat_box.remove(child) chat_data = self.load_chat_data_ui() all_messages = chat_data["messages"] has_streaming_placeholder = self.chat_has_streaming_placeholder(self.current_chat) current_key = self._chat_key(self.current_chat) current_is_streaming = current_key in self.stream_active_chats if has_streaming_placeholder and not current_is_streaming: self.typing_active = True self.typing_row.set_visible(True) if not getattr(self, "_typing_timer_active", False): self._typing_timer_active = True GLib.timeout_add(500, self.animate_typing) else: self.typing_active = False try: self.typing_row.set_visible(False) except Exception: pass total_messages = len(all_messages) if self.loaded_message_start is None: self.loaded_message_start = max( 0, total_messages - self.lazy_chat_initial_count ) # Yeni mesaj geldiyse ve kullanıcı aşağıdaysa son tarafa doğru genişlet if self.loaded_message_start > total_messages: self.loaded_message_start = max( 0, total_messages - self.lazy_chat_initial_count ) messages = all_messages[self.loaded_message_start:] index_offset = self.loaded_message_start has_real_chat = any( isinstance(m, dict) and m.get("role") in ("user", "bot", "assistant") for m in all_messages ) if not has_real_chat: self.show_empty_chat_suggestions() if self.auto_scroll_enabled: GLib.idle_add(self.scroll_to_bottom) return cfg = ensure_base_config(self.load_config()) show_usage = cfg["show_usage"] show_token_value = cfg["show_token_value"] try: token_value = float(str(cfg.get("token_value", "2.0")).replace(",", ".")) except Exception: token_value = 2.0 for local_i, msg in enumerate(messages): if ( isinstance(msg, dict) and msg.get("role") in ("bot", "assistant") and msg.get("streaming") and not str(msg.get("content") or "").strip() ): continue i = index_offset + local_i row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) row.set_hexpand(True) bubble = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) bubble.set_hexpand(False) bubble_wrap = Adw.Clamp() bubble_wrap.set_maximum_size(bubble_max_px) bubble_wrap.set_tightening_threshold(bubble_max_px) bubble_wrap.set_hexpand(False) bubble_wrap.set_child(bubble) # Hover shadow (eski özellik) motion = Gtk.EventControllerMotion() motion.connect("enter", lambda c, b=bubble: b.add_css_class("hovered")) motion.connect("leave", lambda c, b=bubble: b.remove_css_class("hovered")) bubble.add_controller(motion) # Referans preview (mesaj referansla gönderildiyse) groups = msg.get("refs_groups") or [] if isinstance(groups, list) and groups: outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) outer.add_css_class("refs-groups") for g in groups: items = g.get("items") if isinstance(g, dict) else None if not isinstance(items, list) or not items: continue mini = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) mini.add_css_class("refs-preview") # aynı background mantığı for it in items: if not isinstance(it, dict): continue rrole = (it.get("role") or "").strip() rtext = (it.get("text") or "").strip() if not rtext: continue if len(rtext) > 200: rtext = rtext[:200] + "..." line = f"{rrole}: {rtext}" if rrole else rtext lab = Gtk.Label(label=line) lab.set_wrap(True) lab.set_wrap_mode(Pango.WrapMode.WORD_CHAR) lab.set_xalign(0) lab.add_css_class("refs-preview-line") mini.append(lab) outer.append(mini) bubble.append(outer) has_text_content = bool(str(msg.get("content") or "").strip()) if has_text_content or msg.get("i18n_key") or msg.get("error_key") or msg.get("status_key"): content = self.render_message_text(msg) title_block = self.extract_title_block(content) copy_block = self.extract_copy_block(content) apply_block = self.extract_apply_block(content) apply_ops = None apply_err = "" if apply_block is not None: apply_ops, apply_err = self.parse_apply_ops(apply_block) apply_summary = "" if apply_ops: apply_summary = self.build_apply_summary(apply_ops) if copy_block is not None: code_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) code_container.add_css_class("code-block") code_container.set_hexpand(True) code_container.set_halign(Gtk.Align.FILL) top_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) top_row.set_hexpand(True) top_row.set_halign(Gtk.Align.FILL) if title_block: title_label = Gtk.Label() safe = GLib.markup_escape_text(title_block.strip()) title_label.set_markup(f"{safe}") title_label.set_xalign(0) title_label.set_halign(Gtk.Align.START) title_label.set_hexpand(True) title_label.set_wrap(True) title_label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) title_label.set_max_width_chars(28) top_row.append(title_label) copy_btn = Gtk.Button(label=f"{self('o_Copy')}") copy_btn.add_css_class("flat") copy_btn.add_css_class("code-copy-btn") copy_btn.set_focusable(False) copy_btn.set_halign(Gtk.Align.END) copy_btn.set_hexpand(False) self._consume_click(copy_btn) copy_btn.connect("clicked", lambda b, t=copy_block: self.copy_to_clipboard(t)) download_btn = Gtk.Button(label="💾") download_btn.add_css_class("flat") download_btn.add_css_class("code-copy-btn") download_btn.set_focusable(False) download_btn.set_halign(Gtk.Align.END) download_btn.set_hexpand(False) self.bind_i18n(download_btn, "tooltip", "o_Download") self._consume_click(download_btn) suggested_name = "content.txt" if title_block: base = title_block.strip().lower() for ch in ["/", "\\", "\n", "\r", "\t", ":", "*", "?", "\"", "<", ">", "|"]: base = base.replace(ch, "_") suggested_name = (base[:40] or "content") + ".txt" download_btn.connect( "clicked", lambda b, t=copy_block, n=suggested_name: self.download_copy_block_content(t, n) ) copy_actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) copy_actions.set_halign(Gtk.Align.END) copy_actions.set_hexpand(False) copy_actions.append(copy_btn) copy_actions.append(download_btn) copy_wrap = Adw.Clamp() copy_wrap.set_maximum_size(90) copy_wrap.set_child(copy_actions) top_row.append(copy_wrap) code_label = Gtk.Label(label=copy_block) code_label.set_wrap(True) code_label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) code_label.set_xalign(0) code_label.set_halign(Gtk.Align.FILL) code_label.set_hexpand(True) code_label.set_width_chars(0) code_label.set_max_width_chars(9999) code_label.set_selectable(True) code_container.append(top_row) code_container.append(code_label) bubble.append(code_container) else: # 🔹 Normal mesaj show_text = content # Eğer apply ops varsa, bot mesajında content yerine özet satırı göster if msg.get("role") != "user" and apply_ops and apply_summary: show_text = apply_summary label = Gtk.Label(label=show_text) label.set_selectable(True) label.set_wrap(True) label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) label.set_xalign(0) label.set_halign(Gtk.Align.FILL) label.set_width_chars(0) label.set_max_width_chars(9999) label.set_hexpand(True) if "http://" in show_text or "https://" in show_text: link_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) link_box.set_halign(Gtk.Align.START) self.append_text_with_links(link_box, show_text) bubble.append(link_box) else: bubble.append(label) # Eğer bu mesaj streaming placeholder ise, canlı güncelleme bu label'a yazılsın if msg.get("role") != "user" and msg.get("streaming"): self.streaming_label = label self.streaming_row = row if msg.get("streaming") and not show_text.strip(): row.set_visible(False) # Tekli image (legacy) if msg.get("image"): img_path = Path(msg["image"]) if img_path.exists(): pic_overlay = self.build_zoomable_image_widget(str(img_path), 120, 80) bubble.append(pic_overlay) # Çoklu images (yeni) imgs = msg.get("images") if isinstance(imgs, list) and imgs: row_imgs = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) row_imgs.set_halign(Gtk.Align.START) for ip in imgs: p = Path(str(ip)) if not p.exists(): continue pic_overlay = self.build_zoomable_image_widget(str(p), 120, 80) row_imgs.append(pic_overlay) bubble.append(row_imgs) # Files (yeni) files = msg.get("files") if isinstance(files, list) and files: files_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) for f in files: if not isinstance(f, dict): continue name = (f.get("name") or Path(str(f.get("path") or "")).name or "").strip() if not name: continue is_editable = bool(f.get("edit") or f.get("editable")) chip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) chip.add_css_class("refs-preview") icon_name = "document-edit-symbolic" if is_editable else "text-x-generic-symbolic" icon = Gtk.Image.new_from_icon_name(icon_name) chip.append(icon) lab = Gtk.Label(label=name) lab.set_xalign(0) lab.set_max_width_chars(40) lab.set_ellipsize(Pango.EllipsizeMode.END) chip.append(lab) files_box.append(chip) bubble.append(files_box) # Tek seçili balonda action butonları (copy + regenerate) show_actions = (len(self.selected_indexes) == 1 and i in self.selected_indexes) raw_text = self.render_message_text(msg).strip() short_msg = len(raw_text) <= 24 and "\n" not in raw_text and len(raw_text.split()) <= 4 # kısa mesajlarda dışta, uzunlarda ortada overlay use_outside_actions = short_msg if msg.get("role") == "user": bubble.add_css_class("user-bubble") if msg.get("regen"): bubble.add_css_class("user-bubble-regen") elif msg.get("used_refs"): bubble.add_css_class("user-bubble-ref") row.set_halign(Gtk.Align.FILL) bubble_wrap.set_halign(Gtk.Align.END) bubble.set_halign(Gtk.Align.FILL) bubble_wrap.set_margin_start(0) else: bubble.add_css_class("bot-bubble") row.set_halign(Gtk.Align.FILL) bubble_wrap.set_halign(Gtk.Align.START) bubble.set_halign(Gtk.Align.FILL) bubble_wrap.set_margin_end(0) is_selected = i in self.selected_indexes if is_selected: 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: status = st.get("status") err = (st.get("err") or "").strip() if status == "ok": txt = f"✅ {self('o_File_Changed')}" elif status == "fail": txt = self("o_Apply_Failed_Input", error=err) elif status == "cancel": txt = f"⏭️ {self('o_Cancelled')}" 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") # hafif/sönük görünsün istersen bubble.append(status_label) else: content_text = msg.get("content") or "" apply_block = self.extract_apply_block(content_text) usage_parent = bubble if apply_block: ops, parse_err = self.parse_apply_ops(apply_block) # KRİTİK: ops varsa bile ancak path'ler gerçek dosyaysa buton göster valid_ops = [] if isinstance(ops, list) and ops: valid = True for op in ops: p_raw = str((op or {}).get("path") or "").strip() p = Path(p_raw) # absolute değilse veya dosya yoksa -> hiç buton gösterme if (not p.is_absolute()) or (not p.exists()): valid = False break if valid: valid_ops = ops if valid_ops: apply_area = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) apply_area.set_halign(Gtk.Align.START) paths = [] for op in valid_ops: p = str((op or {}).get("path") or "").strip() if p: pp = Path(p) short = f"{pp.parent.name}/{pp.name}" if pp.parent.name else pp.name if short not in paths: paths.append(short) if paths: path_label = Gtk.Label(label="\n".join(paths)) path_label.set_xalign(0) path_label.set_halign(Gtk.Align.START) path_label.set_wrap(True) path_label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) path_label.add_css_class("refs-preview-line") apply_area.append(path_label) btn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) btn_row.set_halign(Gtk.Align.START) apply_btn = Gtk.Button(label=f"{self('o_Apply')}") apply_btn.add_css_class("suggested-action") self._consume_click(apply_btn) reject_btn = Gtk.Button(label=f"{self('o_Reject')}") reject_btn.add_css_class("flat") self._consume_click(reject_btn) def _on_apply(_b, _ops=valid_ops, _idx=i): apply_btn.set_sensitive(False) reject_btn.set_sensitive(False) ok, err = self.apply_ops_yes(_ops) if ok: self._set_apply_status(_idx, "ok") else: self._set_apply_status(_idx, "fail", err) GLib.idle_add(self.apply_failed_to_input, err) self.load_chat() self.scroll_to_bottom(force=True) def _on_reject(_b, _idx=i): self._set_apply_status(_idx, "cancel") self.load_chat() self.scroll_to_bottom(force=True) apply_btn.connect("clicked", _on_apply) reject_btn.connect("clicked", _on_reject) btn_row.append(apply_btn) btn_row.append(reject_btn) apply_area.append(btn_row) bubble.append(apply_area) usage_parent = apply_area # parse_err varsa bile input'a basma; sadece buton göstermemek daha doğru # (çünkü her bot mesajında parse_err input’u kirletir) # ------------------ WEB SEARCH REQUEST BUTTONS ------------------- web_req = msg.get("web_search_request") if isinstance(web_req, dict): query = str(web_req.get("query") or "").strip() status = str(web_req.get("status") or "pending").strip() web_area = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) web_area.set_halign(Gtk.Align.START) info_label = Gtk.Label(label=self("o_Web_Search_Request_With_Query", query=query)) info_label.set_xalign(0) info_label.set_wrap(True) info_label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) info_label.set_halign(Gtk.Align.START) web_area.append(info_label) if status == "pending": btn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) search_btn = Gtk.Button(label=self("o_Search")) search_btn.add_css_class("suggested-action") self._consume_click(search_btn) search_btn.connect( "clicked", lambda _b, idx=i: self.approve_web_search(idx) ) cancel_btn = Gtk.Button(label=self("o_Cancel")) cancel_btn.add_css_class("flat") self._consume_click(cancel_btn) cancel_btn.connect( "clicked", lambda _b, idx=i: self.cancel_web_search(idx) ) btn_row.append(search_btn) btn_row.append(cancel_btn) web_area.append(btn_row) elif status == "searching": status_label = Gtk.Label(label="Searching...") status_label.set_xalign(0) status_label.add_css_class("refs-preview-line") web_area.append(status_label) elif status == "cancelled": status_label = Gtk.Label(label=f"⏭️ {self('o_Web_Search_Cancelled')}") status_label.set_xalign(0) status_label.add_css_class("refs-preview-line") web_area.append(status_label) elif status == "searched": status_label = Gtk.Label(label=f"✅ {self('o_Web_Search_Searched')}") status_label.set_xalign(0) status_label.add_css_class("refs-preview-line") web_area.append(status_label) elif status == "failed": err = str(web_req.get("err") or "").strip() txt = ( f"❌ {self('o_Web_Search_Error_With_Detail', error=err)}" if err else f"❌ {self('o_Web_Search_Failed')}" ) status_label = Gtk.Label(label=txt) status_label.set_xalign(0) status_label.set_wrap(True) status_label.add_css_class("refs-preview-line") web_area.append(status_label) elif status == "error": err = str(web_req.get("err") or "").strip() status_label = Gtk.Label(label=f"Web search error: {err}") status_label.set_xalign(0) status_label.set_wrap(True) status_label.add_css_class("refs-preview-line") web_area.append(status_label) bubble.append(web_area) usage_parent = web_area # ---------------- GENERATED FILE DOWNLOAD BUTTONS ---------------- generated_files = msg.get("generated_files") if isinstance(generated_files, list) and generated_files: download_area = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) download_area.set_halign(Gtk.Align.START) for gf in generated_files: if not isinstance(gf, dict): continue path = str(gf.get("path") or "").strip() name = str(gf.get("name") or Path(path).name).strip() if not path: continue p = Path(path) if not p.exists() or not p.is_file(): continue file_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) file_row.set_halign(Gtk.Align.START) name_label = Gtk.Label(label=name) name_label.set_xalign(0) name_label.set_halign(Gtk.Align.START) name_label.set_max_width_chars(36) name_label.set_ellipsize(Pango.EllipsizeMode.END) name_label.add_css_class("refs-preview-line") download_btn = Gtk.Button(label="💾") download_btn.add_css_class("flat") download_btn.set_tooltip_text(name) download_btn.set_size_request(34, 34) self._consume_click(download_btn) download_btn.connect( "clicked", lambda _b, src=path: self.download_generated_file(src) ) file_row.append(download_btn) file_row.append(name_label) download_area.append(file_row) if download_area.get_first_child() is not None: bubble.append(download_area) usage_parent = download_area # BURASI if apply_block'UN DIŞINDA OLMALI u = msg.get("usage") if show_usage and isinstance(u, dict): p = int(u.get("prompt_tokens", 0) or 0) c = int(u.get("completion_tokens", 0) or 0) t = int(u.get("total_tokens", 0) or 0) 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) usage_label.set_halign(Gtk.Align.START) usage_label.set_wrap(True) usage_label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) usage_label.add_css_class("refs-preview-line") usage_parent.append(usage_label) # Token price her zaman en altta olsun if show_token_value and token_value > 0 and t > 0: # token_value = 1M token başına ücret token_price = (t / 1_000_000) * token_value price_txt = f"{self('o_Total_Token_Price')}: ${token_price:.6f}" price_label = Gtk.Label(label=price_txt) price_label.set_xalign(0) price_label.set_halign(Gtk.Align.START) price_label.set_wrap(True) price_label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) price_label.add_css_class("refs-preview-line") usage_parent.append(price_label) if use_outside_actions: outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) outer.set_hexpand(True) outer.set_halign(Gtk.Align.FILL) left_spacer = Gtk.Box() left_spacer.set_hexpand(True) right_spacer = Gtk.Box() right_spacer.set_hexpand(True) bubble_click = Gtk.GestureClick() bubble_click.connect("pressed", lambda g, n, x, y, idx=i: self.on_message_clicked(idx)) bubble.add_controller(bubble_click) small_overlay = Gtk.Overlay() small_overlay.set_child(bubble_wrap) small_overlay.set_hexpand(False) small_overlay.set_vexpand(False) if show_actions: actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) actions.set_valign(Gtk.Align.CENTER) actions.set_hexpand(False) actions.set_vexpand(False) copy_btn = Gtk.Button(label="📋") copy_btn.add_css_class("flat") copy_btn.add_css_class("action-center-btn") copy_btn.set_size_request(28, 28) copy_btn.set_can_target(True) self._consume_click(copy_btn) copy_btn.connect("clicked", lambda _b, idx=i: self.copy_message_by_index(idx)) regen_btn = Gtk.Button(label="♻") regen_btn.add_css_class("flat") regen_btn.add_css_class("action-center-btn") regen_btn.set_size_request(28, 28) regen_btn.set_can_target(True) self._consume_click(regen_btn) regen_btn.connect("clicked", lambda _b, idx=i: self.regenerate_by_index(idx)) actions.append(copy_btn) actions.append(regen_btn) if msg.get("role") == "user": pass else: actions.set_halign(Gtk.Align.START) actions.set_valign(Gtk.Align.CENTER) actions.set_margin_start(2) actions.set_margin_end(0) if msg.get("role") == "user": outer.append(left_spacer) user_pack = Gtk.Overlay() user_line = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) user_line.set_hexpand(False) user_line.set_vexpand(False) # Butonlar için sadece yatay yer ayırır, yüksekliği büyütmez action_space = Gtk.Box() action_space.set_size_request(140, 1) action_space.set_hexpand(False) action_space.set_vexpand(False) user_line.append(action_space) user_line.append(small_overlay) user_pack.set_child(user_line) user_pack.set_hexpand(False) user_pack.set_vexpand(False) if show_actions: actions.set_halign(Gtk.Align.START) actions.set_valign(Gtk.Align.CENTER) actions.set_margin_start(0) actions.set_margin_end(0) user_pack.add_overlay(actions) user_pack.set_clip_overlay(actions, False) outer.append(user_pack) else: bot_pack = Gtk.Overlay() bot_line = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) bot_line.set_hexpand(False) bot_line.set_vexpand(False) bot_line.append(small_overlay) # Butonlar için sadece yatay alan ayırır, yüksekliği büyütmez action_space = Gtk.Box() action_space.set_size_request(140, 1) action_space.set_hexpand(False) action_space.set_vexpand(False) bot_line.append(action_space) bot_pack.set_child(bot_line) bot_pack.set_hexpand(False) bot_pack.set_vexpand(False) if show_actions: actions.set_halign(Gtk.Align.END) actions.set_valign(Gtk.Align.CENTER) actions.set_margin_start(0) actions.set_margin_end(0) bot_pack.add_overlay(actions) bot_pack.set_clip_overlay(actions, False) outer.append(bot_pack) outer.append(right_spacer) row.append(outer) else: bubble_overlay = Gtk.Overlay() bubble_overlay.set_child(bubble_wrap) bubble_overlay.set_clip_overlay(bubble_wrap, False) if is_selected: bubble.add_css_class("selected") bubble_overlay.set_halign(Gtk.Align.END if msg.get("role") == "user" else Gtk.Align.START) bubble_overlay.set_valign(Gtk.Align.FILL) bubble_overlay.set_hexpand(False) bubble_overlay.set_vexpand(False) wrapper_click = Gtk.GestureClick() wrapper_click.connect("pressed", lambda g, n, x, y, idx=i: self.on_message_clicked(idx)) bubble_overlay.add_controller(wrapper_click) if show_actions: dim = Gtk.Box() dim.add_css_class("action-dim") dim.set_halign(Gtk.Align.FILL) dim.set_valign(Gtk.Align.FILL) dim.set_hexpand(False) dim.set_vexpand(False) if msg.get("role") == "user": dim.set_margin_end(10) else: dim.set_margin_start(10) dim_click = Gtk.GestureClick() dim_click.connect("pressed", lambda *_: self.clear_selection()) dim.add_controller(dim_click) bubble_overlay.add_overlay(dim) actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) actions.set_halign(Gtk.Align.CENTER) actions.set_valign(Gtk.Align.CENTER) copy_btn = Gtk.Button(label="📋") copy_btn.add_css_class("flat") copy_btn.add_css_class("action-center-btn") copy_btn.set_size_request(36, 36) self._consume_click(copy_btn) copy_btn.connect("clicked", lambda _b, idx=i: self.copy_message_by_index(idx)) regen_btn = Gtk.Button(label="♻") regen_btn.add_css_class("flat") regen_btn.add_css_class("action-center-btn") regen_btn.set_size_request(36, 36) self._consume_click(regen_btn) regen_btn.connect("clicked", lambda _b, idx=i: self.regenerate_by_index(idx)) actions.append(copy_btn) actions.append(regen_btn) bubble_overlay.add_overlay(actions) if msg.get("role") == "user": long_outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) long_outer.set_hexpand(True) long_outer.set_halign(Gtk.Align.FILL) left_spacer = Gtk.Box() left_spacer.set_hexpand(True) long_outer.append(left_spacer) long_outer.append(bubble_overlay) row.append(long_outer) else: long_outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) long_outer.set_hexpand(True) long_outer.set_halign(Gtk.Align.FILL) right_spacer = Gtk.Box() right_spacer.set_hexpand(True) long_outer.append(bubble_overlay) long_outer.append(right_spacer) row.append(long_outer) self.chat_box.append(row) GLib.idle_add(self.scroll_to_bottom) def load_older_messages(self): if getattr(self, "_loading_older_messages", False): return try: chat_data = self.load_chat_data_ui() total = len(chat_data["messages"]) if total <= 0: return old_start = self.loaded_message_start if old_start is None: old_start = max(0, total - self.lazy_chat_initial_count) if old_start <= 0: return self._loading_older_messages = True self.auto_scroll_enabled = False adj = self.scroll.get_vadjustment() old_upper = adj.get_upper() if adj else 0 old_value = adj.get_value() if adj else 0 self.loaded_message_start = max( 0, old_start - self.lazy_chat_batch_count ) self.load_chat() def restore_after_prepend(): try: adj2 = self.scroll.get_vadjustment() if adj2: new_upper = adj2.get_upper() delta = new_upper - old_upper adj2.set_value(old_value + delta) except Exception: pass self._loading_older_messages = False return False GLib.idle_add(restore_after_prepend) GLib.timeout_add(60, restore_after_prepend) except Exception: self._loading_older_messages = False def restore_scroll_position_later(self, old_scroll): if old_scroll is None: return def restore_scroll(): try: adj = self.scroll.get_vadjustment() if adj: max_val = max(0, adj.get_upper() - adj.get_page_size()) adj.set_value(min(old_scroll, max_val)) except Exception: pass return False GLib.idle_add(restore_scroll) GLib.timeout_add(30, restore_scroll) GLib.timeout_add(80, restore_scroll) GLib.timeout_add(160, restore_scroll) def on_message_clicked(self, index): self.auto_scroll_enabled = False # Mevcut scroll pozisyonunu koru old_scroll = None try: adj = self.scroll.get_vadjustment() if adj: old_scroll = adj.get_value() except Exception: pass if not self.selection_mode: self.selection_mode = True self.selected_indexes.add(index) else: if index in self.selected_indexes: self.selected_indexes.remove(index) else: self.selected_indexes.add(index) if not self.selected_indexes: self.selection_mode = False self.update_selection_label() self.load_chat() # load_chat sonrası GTK focus/selectable label yüzünden zıplarsa geri al self.restore_scroll_position_later(old_scroll) def update_selection_label(self): if not self.selected_indexes: self.selection_label.set_text("") self.selection_row.set_opacity(0.0) self.selection_row.set_sensitive(False) else: self.selection_label.set_text(f"{len(self.selected_indexes)} {self('o_Reference_Message')}") self.selection_row.set_opacity(1.0) self.selection_row.set_sensitive(True) def is_at_bottom(self, slack=6): adj = self.scroll.get_vadjustment() if hasattr(self, "scroll") else None if not adj: return True return adj.get_value() >= (adj.get_upper() - adj.get_page_size() - slack) def _sync_scroll_btn_margin(self): try: preview_h = self.preview_row.get_allocated_height() if self.preview_row.get_visible() else 0 input_h = self.input_card.get_allocated_height() if getattr(self, "input_card", None) else 0 if input_h <= 0: input_h = 80 self.scroll_btn.set_margin_bottom(input_h + preview_h + 20) except Exception: pass return False def scroll_to_bottom(self, force=False): if not self.auto_scroll_enabled and not force: return adj = self.scroll.get_vadjustment() if adj: adj.set_value(adj.get_upper() - adj.get_page_size()) def on_scroll_button_clicked(self, *_): self.auto_scroll_enabled = True self.scroll_to_bottom(force=True) self.scroll_btn.set_visible(False) def on_scroll_changed(self, adjustment): value = adjustment.get_value() # Yukarı yaklaşınca eski mesajları yükle if value < 300: GLib.idle_add(self.load_older_messages) # en altta mı kontrol et at_bottom = value >= ( adjustment.get_upper() - adjustment.get_page_size() - 800 ) if at_bottom: self.scroll_btn.set_visible(False) else: self.scroll_btn.set_visible(True) # ---------------- VOICE INPUT ------------------- def _detect_pw_desktop_target(self) -> str | None: """ PipeWire/Pulse tarafında masaüstü sesini almak için monitor source bulmaya çalışır. """ preferred = self._get_preferred_mic_from_config() if preferred: return preferred pactl = shutil.which("pactl") if pactl: try: p = subprocess.run( [pactl, "list", "short", "sources"], capture_output=True, text=True ) if p.returncode == 0: lines = (p.stdout or "").splitlines() # Önce monitor source ara for line in lines: parts = line.split("\t") if len(parts) < 2: continue source_name = parts[1].strip() if source_name and self._is_monitor_like_name(source_name): return source_name # hiç bulunamazsa None dön return None except Exception: pass return None def _get_use_desktop_voice(self) -> bool: cfg = ensure_base_config(self.load_config()) return cfg["use_desktop_voice"] def _is_monitor_like_name(self, name: str) -> bool: s = str(name or "").strip().lower() bad_words = [ "monitor", ".monitor", "loopback", "stereo mix", "what u hear", "mix monitor", "output" ] return any(w in s for w in bad_words) def _get_preferred_mic_from_config(self) -> str: cfg = self.load_config() return str(cfg.get("mic_device") or "").strip() def _detect_pw_mic_target(self) -> str | None: """ PipeWire/Pulse tarafında gerçek mikrofonu bulmaya çalışır. Monitor/loopback kaynaklarını elemeye çalışır. """ preferred = self._get_preferred_mic_from_config() if preferred: return preferred pactl = shutil.which("pactl") if pactl: try: p = subprocess.run( [pactl, "list", "short", "sources"], capture_output=True, text=True ) if p.returncode == 0: lines = (p.stdout or "").splitlines() # Önce gerçek mikrofon ara for line in lines: parts = line.split("\t") if len(parts) < 2: continue source_name = parts[1].strip() if source_name and not self._is_monitor_like_name(source_name): return source_name # fallback olarak ilk source for line in lines: parts = line.split("\t") if len(parts) < 2: continue source_name = parts[1].strip() if source_name: return source_name except Exception: pass wpctl = shutil.which("wpctl") if wpctl: try: p = subprocess.run( [wpctl, "status"], capture_output=True, text=True ) if p.returncode == 0: for line in (p.stdout or "").splitlines(): low = line.lower() if "*" in line and "source" in low and "monitor" not in low: parts = line.strip().split() for token in parts: token = token.strip(".") if token.isdigit(): return token except Exception: pass return None def _detect_arecord_mic_device(self) -> str | None: """ arecord için hw/plughw cihazı bulmaya çalışır. En güvenlisi config'ten gelmesi ama fallback de ekliyoruz. """ preferred = self._get_preferred_mic_from_config() if preferred: return preferred ar = shutil.which("arecord") if not ar: return None try: p = subprocess.run( [ar, "-L"], capture_output=True, text=True ) if p.returncode != 0: return None lines = [x.strip() for x in (p.stdout or "").splitlines() if x.strip()] # pulse varsa önce onu kullanma; çünkü bazen monitor'a gidebilir # gerçek cihaz olan plughw/hw benzeri bir giriş daha güvenli for name in lines: low = name.lower() if low.startswith("plughw:") and "null" not in low: return name for name in lines: low = name.lower() if low.startswith("hw:") and "null" not in low: return name for name in lines: low = name.lower() if low == "default": return name except Exception: pass return None def toggle_voice_input(self, *_): # Eğer kayıt açıksa: durdur + transcribe if self._voice_proc and self._voice_proc.poll() is None: try: # pw-record için en güvenlisi SIGINT (Ctrl+C gibi) self._voice_proc.send_signal(signal.SIGINT) except Exception: try: self._voice_proc.terminate() except Exception: pass try: self._voice_proc.wait(timeout=3) except Exception: try: self._voice_proc.kill() except Exception: pass self._voice_proc = None self.mic_btn.set_label("🎙️") self.bind_i18n(self.mic_btn, "tooltip", "o_Microphone") # transcribe arka planda threading.Thread(target=self._transcribe_last_audio, daemon=True).start() return # Kayıt başlat tmp = Path(tempfile.gettempdir()) stamp = datetime.now().strftime("%Y%m%d-%H%M%S") self._last_wav = tmp / f"capture-ai-mic-{stamp}.wav" pw = shutil.which("pw-record") ar = shutil.which("arecord") use_desktop_voice = self._get_use_desktop_voice() if pw: if use_desktop_voice: target = self._detect_pw_desktop_target() if not target: self.handle_ai_error({ "role": "bot", "error_key": "o_Desktop_Audio_Monitor_Not_Found", "error_params": {} }) return else: target = self._detect_pw_mic_target() if not target: self.handle_ai_error({ "role": "bot", "error_key": "o_Microphone_Source_Not_Found", "error_params": {} }) return cmd = [pw, "--rate", "16000", "--channels", "1", "--target", str(target), str(self._last_wav)] elif ar: # arecord tarafında masaüstü sesi yakalama her sistemde stabil değil. # Burada mikrofon için normal davranıyoruz. # Desktop sesi gerekiyorsa PipeWire/pw-record tercih edilmeli. if use_desktop_voice: self.handle_ai_error({ "role": "bot", "error_key": "o_Desktop_Audio_Requires_PWRecord", "error_params": {} }) return device = self._detect_arecord_mic_device() if not device: self.handle_ai_error({ "role": "bot", "error_key": "o_Microphone_Device_Not_Found", "error_params": {} }) return cmd = [ar, "-D", str(device), "-f", "S16_LE", "-r", "16000", "-c", "1", str(self._last_wav)] else: self.handle_ai_error({ "role": "bot", "error_key": "o_Audio_Recorder_Not_Found", "error_params": {} }) return try: self._voice_proc = subprocess.Popen( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) except Exception as e: self._voice_proc = None self.handle_ai_error({ "role": "bot", "error_key": "o_Audio_Record_Start_Failed", "error_params": { "error": str(e) } }) return self.mic_btn.set_label("⏹") self.bind_i18n(self.mic_btn, "tooltip", "o_Stop_Recording") def _normalize_stt_text(self, text: str) -> str: t = (text or "").strip() if not t: return "__NO_SPEECH__" # Karşılaştırma için normalize et normalized = ( t.replace("’", "'") .replace("“", '"') .replace("”", '"') .strip() ) low = normalized.lower() # Direkt EMPTY_AUDIO veya içinde geçiyorsa if "empty_audio" in low: return "__NO_SPEECH__" # Gürültü / sessizlik / bekleme mesajları bad_patterns = [ "i', sorry", "i'm here", "i am here", "please upload", "please provide", "provide the audio", "share the audio", "i can transcribe", "i will transcribe", "i'll transcribe", "upload the audio", "audio file", "please provide the audio", "no audio", "cannot transcribe", "can't transcribe", "no speech", "no clear speech", "there's no clear speech detected", "there is no clear speech detected", "silence", "only silence", "noise", "only noise", "background sounds", "music only", "please speak when you're ready", "please speak when you are ready", "i'll transcribe your speech", "i will transcribe your speech", "ready, and i'll transcribe", "ready, and i will transcribe", "microphone input", "sure. please speak", "speak when you're ready", "speak when you are ready", "could you please repeat", "repeat the part of the sentence", "so that i can transcribe it accurately", "something might be unclear or incomplete", "it seems like something might be unclear or incomplete", ] if any(p in low for p in bad_patterns): return "__NO_SPEECH__" if ( len(t.split()) > 12 and t.count(",") + t.count(".") > 1 ): return "__NO_SPEECH__" # Çok kısa ve anlamsız bazı kalıplar junk_exact = { "empty audio", "empty_audio", "no speech detected", "no clear speech detected", } if low in junk_exact: return "__NO_SPEECH__" return t def _append_to_input(self, text: str): buf = self.textview.get_buffer() it = buf.get_iter_at_mark(buf.get_insert()) if buf.get_char_count() > 0: buf.insert(it, " ") buf.insert(it, text) def _transcribe_last_audio(self): wav = self._last_wav if not wav or not Path(wav).exists(): return cfg = ensure_base_config(self.load_config()) is_online = cfg["is_mic_online"] try: if is_online: text = self._stt_online_openrouter(wav, cfg) else: text = self._stt_offline_whisper_cpp(wav, cfg) except Exception as e: GLib.idle_add( self.handle_ai_error, { "role": "bot", "error_key": "o_STT_Error", "error_params": { "error": str(e) } } ) return print("STT MODE:", "online" if is_online else "offline") print("STT RAW TEXT:", repr(text)) if text == "__NO_SPEECH__": GLib.idle_add( self.handle_ai_error, { "role": "bot", "error_key": "o_No_Speech_Detected", "error_params": {} } ) return if text: GLib.idle_add(self._append_to_input, text.strip()) def _stt_online_openrouter(self, wav_path: Path, cfg: dict) -> str: import requests, json key = (cfg.get("open_router_key") or "").strip() if not key: raise RuntimeError(self("o_OpenRouter_STT_Key_Missing")) model = (cfg.get("stt_model_online") or "openai/gpt-audio-mini").strip() audio_b64 = base64.b64encode(Path(wav_path).read_bytes()).decode("utf-8") payload = { "model": model, "messages": [ { "role": "user", "content": [ {"type": "text", "text": "Speech-to-text only. " "Return only verbatim transcription. " "No replies, no explanations, no apologies, no labels. " "If speech is unclear or missing, return exactly EMPTY_AUDIO." }, {"type": "input_audio", "input_audio": {"data": audio_b64, "format": "wav"}}, ], } ], "stream": False, } r = requests.post( "https://openrouter.ai/api/v1/chat/completions", headers={ "Authorization": f"Bearer {key}", "Content-Type": "application/json", "HTTP-Referer": "http://localhost", "X-Title": "capture-ai", }, json=payload, timeout=120, ) if r.status_code != 200: raise RuntimeError(r.text[:500]) data = r.json() raw_text = (data["choices"][0]["message"]["content"] or "").strip() return self._normalize_stt_text(raw_text) def _stt_offline_whisper_cpp(self, wav_path: Path, cfg: dict) -> str: import tempfile bin_path = (cfg.get("whisper_cpp_bin") or "").strip() model_path = (cfg.get("whisper_cpp_model") or "").strip() if not bin_path or not Path(bin_path).exists(): raise RuntimeError(self("o_Whisper_Bin_Not_Found", path=bin_path)) if not model_path or not Path(model_path).exists(): raise RuntimeError(self("o_Whisper_Model_Not_Found", path=model_path)) outdir = Path(tempfile.gettempdir()) / "capture-ai-stt" outdir.mkdir(parents=True, exist_ok=True) outbase = outdir / Path(wav_path).stem cmd = [ bin_path, "-m", model_path, "-f", str(wav_path), "-l", "tr", "-otxt", "-of", str(outbase), ] p = subprocess.run(cmd, capture_output=True, text=True) if p.returncode != 0: raise RuntimeError((p.stderr or p.stdout or self("o_Whisper_Failed"))[:800]) txt = Path(str(outbase) + ".txt") if not txt.exists(): # bazı sürümlerde .txt yerine stdout'a basabilir—fallback s = (p.stdout or "").strip() return self._normalize_stt_text(s) raw_text = txt.read_text(encoding="utf-8", errors="ignore").strip() return self._normalize_stt_text(raw_text) # ---------------- CHANGE CODE FILES ------------- def apply_failed_to_input(self, err: str): if not err: err = self("o_Unknown_Error") buf = self.textview.get_buffer() # input'u temizle ve hata bas (istersen temizleme, sadece append de yapabilirsin) buf.set_text(self("o_Apply_Failed_Input", error=err)) self.textview.grab_focus() def is_file_editable(self, op): op = op or {} # 1. AI explicit flag if "editable" in op: return bool(op["editable"]) # 2. patch / diff varsa editable say if op.get("diff") or op.get("patch"): return True # 3. uzantıya göre fallback p = str(op.get("path") or "").lower() editable_exts = (".py", ".js", ".ts", ".json", ".cpp", ".c", ".h", ".css", ".html") return p.endswith(editable_exts) def _apply_edit_op(self, file_path: str, find: str, mode: str, text: str) -> tuple[bool, str]: """ mode: "before" | "after" | "replace" find: dosyada bulunacak anchor string text: eklenecek / değiştirilecek içerik return: (ok, err) """ try: p = Path(file_path) if not p.exists(): return False, self("o_Apply_File_Not_Found", path=p) original = p.read_text(encoding="utf-8", errors="ignore") idx = original.find(find) if idx < 0: return False, self("o_Apply_Find_Failed", name=p.name) if mode == "before": new_content = original[:idx] + text + original[idx:] elif mode == "after": new_content = original[:idx + len(find)] + text + original[idx + len(find):] elif mode == "replace": new_content = original.replace(find, text, 1) # sadece ilk eşleşme else: return False, self("o_Apply_Unknown_Mode", mode=mode) if new_content == original: return False, self("o_Apply_No_Changes") p.write_text(new_content, encoding="utf-8") return True, "" except Exception as e: return False, self("o_Apply_Exception", error=e) def apply_ops_yes(self, ops) -> tuple[bool, str]: """ ops: parse edilmiş operasyon listesi return: (ok, err) """ if not isinstance(ops, list) or not ops: return False, self("o_Apply_No_Ops") for op in ops: path = str(op.get("path") or "").strip() find = str(op.get("find") or "") mode = str(op.get("mode") or "").strip().lower() text = str(op.get("text") or "") ok, err = self._apply_edit_op(path, find, mode, text) if not ok: return False, err return True, "" def _set_apply_status(self, msg_index: int, status: str, err: str = ""): try: chat_data = self.load_chat_data_ui() messages = chat_data["messages"] except Exception: return if not (0 <= msg_index < len(messages)): return messages[msg_index]["apply_status"] = { "status": status, "err": (err or "").strip() } try: chat_data["messages"] = messages self.save_chat_data_ui(chat_data) except Exception: return # ---------------- ASK WEB SEARCH ----------------- def _set_web_search_status(self, msg_index: int, status: str, err: str = ""): try: chat_data = self.load_chat_data_ui() messages = chat_data["messages"] except Exception: return if not (0 <= msg_index < len(messages)): return req = messages[msg_index].get("web_search_request") if not isinstance(req, dict): return req["status"] = status req["err"] = (err or "").strip() messages[msg_index]["web_search_request"] = req try: chat_data["messages"] = messages self.save_chat_data_ui(chat_data) except Exception: return def mark_latest_web_search_done(self, chat_path, ok: bool, err: str = ""): try: chat_data = self.load_chat_data_ui(chat_path) messages = chat_data["messages"] for msg in reversed(messages): req = msg.get("web_search_request") if isinstance(msg, dict) else None if isinstance(req, dict) and req.get("status") == "searching": req["status"] = "searched" if ok else "failed" req["err"] = str(err or "").strip() msg["web_search_request"] = req break chat_data["messages"] = messages self.save_chat_data_ui(chat_data, chat_path) if Path(self.current_chat).resolve() == Path(chat_path).resolve(): self.load_chat() except Exception: pass return False def approve_web_search(self, msg_index: int): try: chat_data = self.load_chat_data_ui() messages = chat_data["messages"] except Exception: return if not (0 <= msg_index < len(messages)): return msg = messages[msg_index] req = msg.get("web_search_request") if not isinstance(req, dict): return query = str(req.get("query") or "").strip() if not query: self._set_web_search_status(msg_index, "error", "Empty search query.") self.load_chat() return self._set_web_search_status(msg_index, "searching") self.load_chat() chat_path_for_ai = Path(self.current_chat) selected_arg = "" if self.selected_indexes: selected_arg = ",".join(map(str, sorted(self.selected_indexes))) threading.Thread( target=self.call_ai, args=(selected_arg, chat_path_for_ai, query), daemon=True ).start() def cancel_web_search(self, msg_index: int): self._set_web_search_status(msg_index, "cancelled") self.load_chat() # ---------------- STOP GENERATE ----------------- def stop_ai_generation(self, chat_path=None): chat_path = Path(chat_path or self.current_chat) key = self._chat_key(chat_path) self.ai_stop_requested_chats.add(key) proc = self.ai_procs.get(key) if proc is not None: try: proc.terminate() except Exception: try: proc.kill() except Exception: pass self.stream_active_chats.discard(key) self.stream_ui_ready_chats.discard(key) if Path(self.current_chat).resolve() == chat_path.resolve(): self.typing_active = False try: self.typing_row.set_visible(False) except Exception: pass self.set_generating_state(False, chat_path) try: chat_data = self.load_chat_data_ui(chat_path) messages = chat_data["messages"] changed = False if messages and isinstance(messages[-1], dict): last = messages[-1] if last.get("role") in ("bot", "assistant") and last.get("streaming"): content = (last.get("content") or "").strip() if not content: messages.pop() changed = True 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: chat_data["messages"] = messages self.save_chat_data_ui(chat_data, chat_path) except Exception: pass self.ai_procs.pop(key, None) if Path(self.current_chat).resolve() == chat_path.resolve(): self.load_chat() self.streaming_row = None self.streaming_label = None self.scroll_to_bottom(force=True) def on_send_button_clicked(self, *_): if self.is_chat_generating(self.current_chat): self.stop_ai_generation(self.current_chat) else: self.send_message(None) def _chat_key(self, chat_path=None): return str(Path(chat_path or self.current_chat).resolve()) def is_chat_generating(self, chat_path=None) -> bool: return self._chat_key(chat_path) in self.generating_chats def refresh_current_generating_ui(self): self.set_generating_state( self.is_chat_generating(self.current_chat), self.current_chat ) def set_generating_state(self, generating: bool, chat_path=None): chat_path = Path(chat_path or self.current_chat) key = self._chat_key(chat_path) if generating: self.generating_chats.add(key) else: self.generating_chats.discard(key) # Eski kodlarla uyumluluk için: self.is_generating = self.is_chat_generating(self.current_chat) # Sadece ekrandaki aktif chat için send butonunu değiştir if Path(self.current_chat).resolve() != chat_path.resolve(): return if hasattr(self, "send_btn") and self.send_btn: if self.is_chat_generating(chat_path): self.send_btn.set_label("☐") self.bind_i18n(self.send_btn, "tooltip", "o_Stop_Button") else: self.send_btn.set_label("↑") self.bind_i18n(self.send_btn, "tooltip", "o_Send_Button") # ---------------- SEND / CALL AI ---------------- def send_message(self, widget): buffer = self.textview.get_buffer() start, end = buffer.get_bounds() message = buffer.get_text(start, end, True).strip() if not message: return chat_path_for_ai = Path(self.current_chat) if self.is_chat_generating(chat_path_for_ai): return chat_data = self.load_chat_data_ui() messages = chat_data["messages"] new_message = {"role": "user", "content": message} if self.pending_images: new_message["images"] = [str(Path(p).resolve()) for p in self.pending_images if Path(p).exists()] if self.pending_files: new_message["files"] = [] for f in self.pending_files: fp = Path(str(f.get("path",""))) if fp.exists(): new_message["files"].append({ "path": str(fp.resolve()), "name": fp.name, "edit": bool(f.get("edit")), # <-- önemli }) # referansla gönderildiyse işaretle # referansla gönderildiyse: indeks + snapshot önizleme kaydet if self.selected_indexes: selected = sorted(self.selected_indexes) # AI'ya da gitsin diye zinciri genişlet (fotoğraf vs kaybolmasın) expanded = set() for ridx in selected: if 0 <= ridx < len(messages): expanded |= self.expand_ref_chain(messages, ridx) new_message["used_refs"] = sorted(expanded) # Görünüm için: her seçilen referans kendi balonu gibi bir "group" olsun # group = (o mesajın eski refs_preview'si) + (o mesajın kendisi) groups = [] for ridx in selected: if not (0 <= ridx < len(messages)): continue rmsg = messages[ridx] group_items = [] # Eğer bu referans mesajı daha önce referansla gönderildiyse, # onun içindeki refs_groups'u (veya eski refs_preview'u) da gösterelim rg = rmsg.get("refs_groups") if isinstance(rg, list) and rg: # refs_groups varsa, içindeki tüm item’ları düzleştirip ekleyelim for g in rg: items = g.get("items") if isinstance(g, dict) else None if isinstance(items, list): for it in items: if isinstance(it, dict) and it.get("text"): group_items.append({"role": it.get("role",""), "text": it.get("text","")}) else: # Eski sürümden kalan refs_preview varsa onu da ekle rp = rmsg.get("refs_preview") if isinstance(rp, list) and rp: for it in rp: if isinstance(it, dict) and it.get("text"): group_items.append({"role": it.get("role",""), "text": it.get("text","")}) # en sona referans mesajın kendisini ekle (bağlam bütünlüğü için) group_items.append(self.pack_preview_item(rmsg)) groups.append({"items": group_items}) new_message["refs_groups"] = groups chat_data = self.load_chat_data_ui() messages = chat_data["messages"] messages.append(new_message) messages.append({"role": "bot", "content": "", "streaming": True}) chat_data["messages"] = messages self.save_chat_data_ui(chat_data) # Streaming için placeholder bot mesajı ekle (UI bu balonu canlı günceller) self._streaming_bot_idx = len(messages) - 1 chat_data["messages"] = messages self.save_chat_data_ui(chat_data) buffer.set_text("") # preview temizle self.pending_images.clear() self.pending_files.clear() self.refresh_attachments_preview() selected_arg = "" if self.selected_indexes: # aynı expanded used_refs setini yeniden hesapla (ya da new_message["used_refs"]'ten al) ref_idxs = new_message.get("used_refs") or [] if isinstance(ref_idxs, list) and ref_idxs: selected_arg = ",".join(str(i) for i in ref_idxs if isinstance(i, int)) self.selected_indexes.clear() self.selection_mode = False self.update_selection_label() self.load_chat() self.show_typing_indicator(chat_path_for_ai) key = self._chat_key(chat_path_for_ai) self.ai_stop_requested_chats.discard(key) self.set_generating_state(True, chat_path_for_ai) self.stream_ui_ready_chats.discard(key) self.stream_active_chats.discard(key) threading.Thread( target=self.call_ai, args=(selected_arg, chat_path_for_ai), daemon=True ).start() def _consume_click(self, widget: Gtk.Widget): """ Bu widget'a tıklanınca click event'ini yukarı (bubble click handler'ına) taşırma. """ g = Gtk.GestureClick() g.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) def on_pressed(gesture, n_press, x, y): gesture.set_state(Gtk.EventSequenceState.CLAIMED) g.connect("pressed", on_pressed) widget.add_controller(g) def extract_title_block(self, text: str): lines = text.splitlines() start = None end = None for i, line in enumerate(lines): if line.strip() == "title": if start is None: start = i else: end = i break if start is not None and end is not None and end > start: return "\n".join(lines[start + 1:end]).strip() return None def extract_copy_block(self, text: str): lines = text.splitlines() start = None end = None for i, line in enumerate(lines): if line.strip() == "copy": if start is None: start = i else: end = i break if start is not None and end is not None and end > start: return "\n".join(lines[start + 1:end]).strip() return None def extract_apply_block(self, text: str): lines = text.splitlines() start = None end = None for i, line in enumerate(lines): if line.strip() == "apply": if start is None: start = i else: end = i break if start is not None and end is not None and end > start: return "\n".join(lines[start + 1:end]).strip() return None def parse_apply_ops(self, apply_text: str): """ apply bloğunun içindeki JSON’u parse eder. Kabul edilen formatlar: - Tek dict: {"path":..., "find":..., "mode":..., "text":...} - Liste: [{"path":...}, {...}] Return: (ops_list, err_str) """ try: raw = (apply_text or "").strip() if not raw: return None, "empty apply block" data = json.loads(raw) if isinstance(data, dict): ops = [data] elif isinstance(data, list): ops = data else: return None, "apply JSON must be object or array" # minimum alan doğrulama clean = [] for i, op in enumerate(ops): if not isinstance(op, dict): return None, f"op #{i} is not an object" clean.append(op) return clean, "" except Exception as e: return None, f"invalid apply JSON: {e}" def _short(self, s: str, n: int = 120) -> str: s = (s or "").replace("\n", "\\n") return s if len(s) <= n else (s[:n] + "...") def build_apply_summary(self, ops: list[dict]) -> str: """ ops -> 1 satırlık özet üretir. (Şimdilik ilk op'u baz alıyoruz) """ if not isinstance(ops, list) or not ops or not isinstance(ops[0], dict): return "" op = ops[0] mode = str(op.get("mode") or "").strip().lower() find = str(op.get("find") or "") text = str(op.get("text") or "") if mode not in ("replace", "before", "after"): mode = "replace" # "Bu satırı replace ediyorum: -> " mode_text = self(f"o_Apply_Mode_{mode}") return f"{mode_text}: {self._short(find)} -> {self._short(text)}" def _load_scaled_texture(self, image_path: str, width: int, height: int): try: pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( image_path, max(1, int(width)), max(1, int(height)), True ) return Gdk.Texture.new_for_pixbuf(pixbuf) except Exception as e: print("scaled texture load error:", e) return None def build_zoomable_image_widget(self, image_path: str, width: int, height: int): p = Path(str(image_path)) overlay = Gtk.Overlay() pic = Gtk.Picture() pic.set_can_shrink(True) pic.set_keep_aspect_ratio(True) pic.set_content_fit(Gtk.ContentFit.COVER) pic.set_size_request(width, height) try: texture = self._load_scaled_texture(str(p), width, height) if texture is None: raise RuntimeError("texture is None") pic.set_paintable(texture) except Exception as e: print("image preview load error:", e) fallback = Gtk.Label(label=self("o_Image")) fallback.set_size_request(width, height) fallback.set_wrap(True) fallback.set_xalign(0.5) fallback.set_yalign(0.5) overlay.set_child(fallback) else: overlay.set_child(pic) zoom_btn = Gtk.Button(label="⛶") zoom_btn.add_css_class("flat") zoom_btn.add_css_class("image-zoom-btn") zoom_btn.set_focusable(False) zoom_btn.set_halign(Gtk.Align.START) zoom_btn.set_valign(Gtk.Align.START) zoom_btn.set_margin_top(4) zoom_btn.set_margin_start(4) zoom_btn.set_size_request(26, 26) self.bind_i18n(zoom_btn, "tooltip", "o_Zoom_Attachments") self._consume_click(zoom_btn) zoom_btn.connect("clicked", lambda *_: self.open_image_viewer(str(p))) overlay.add_overlay(zoom_btn) return overlay def open_image_viewer(self, image_path: str): p = Path(str(image_path)) if not p.exists(): return win = Gtk.Window() win.set_transient_for(self.win) win.set_modal(True) win.set_title(p.name) win.set_default_size(1100, 800) outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) outer.set_margin_top(12) outer.set_margin_bottom(12) outer.set_margin_start(12) outer.set_margin_end(12) header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) title = Gtk.Label(label=p.name) title.set_xalign(0) title.set_hexpand(True) header.append(title) close_btn = Gtk.Button(label="✕") close_btn.add_css_class("flat") self.bind_i18n(close_btn, "tooltip", "o_Close_Attachments") close_btn.connect("clicked", lambda *_: win.close()) header.append(close_btn) outer.append(header) scroller = Gtk.ScrolledWindow() scroller.set_hexpand(True) scroller.set_vexpand(True) pic = Gtk.Picture() pic.set_can_shrink(True) pic.set_keep_aspect_ratio(True) pic.set_content_fit(Gtk.ContentFit.CONTAIN) pic.set_hexpand(True) pic.set_vexpand(True) pic.set_halign(Gtk.Align.CENTER) pic.set_valign(Gtk.Align.CENTER) try: texture = Gdk.Texture.new_from_filename(str(p)) pic.set_paintable(texture) except Exception as e: print("image viewer load error:", e) fallback = Gtk.Label( label=self("o_Image_Could_Not_Be_Loaded", path=p.name) ) fallback.set_wrap(True) fallback.set_xalign(0.5) fallback.set_yalign(0.5) scroller.set_child(fallback) else: scroller.set_child(pic) win.set_child(outer) key_controller = Gtk.EventControllerKey() def on_key_pressed(_controller, keyval, _keycode, _state): if keyval == Gdk.KEY_Escape: win.close() return True return False key_controller.connect("key-pressed", on_key_pressed) win.add_controller(key_controller) outer.append(scroller) win.set_child(outer) win.present() def copy_to_clipboard(self, text: str): display = Gdk.Display.get_default() if not display: return clipboard = display.get_clipboard() clipboard.set(text) def call_ai(self, selected_arg="", chat_path_for_ai=None, approved_web_search_query=None): chat_path_for_ai = Path(chat_path_for_ai or self.current_chat) context_mode = self.get_context_mode(chat_path_for_ai) cmd = [ sys.executable, "-u", AI_SCRIPT, str(chat_path_for_ai), selected_arg or "", "--context-mode", context_mode, ] if approved_web_search_query: cmd += [ "--approved-web-search-query", str(approved_web_search_query) ] try: env = os.environ.copy() env["PYTHONUTF8"] = "1" env["PYTHONIOENCODING"] = "utf-8" env["PYTHONUNBUFFERED"] = "1" proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0, text=False, env=env ) key = self._chat_key(chat_path_for_ai) self.ai_procs[key] = proc if proc.stdout is None or proc.stderr is None: raise RuntimeError("Popen stdout/stderr PIPE değil (None geldi)") import select decoder = codecs.getincrementaldecoder("utf-8")("replace") partial = "" stderr_chunks = [] json_mode = False out_fd = proc.stdout.fileno() err_fd = proc.stderr.fileno() stream_buffer = [] stream_lock = threading.Lock() stream_timer_active = True stream_failed = False STREAM_FLUSH_MS = 10 # 60-120 arası iyi. Daha yavaş istersen 120 yap. STREAM_CHARS_PER_FLUSH = 8 def flush_stream_buffer(): nonlocal stream_timer_active if key in self.ai_stop_requested_chats: return False if (not stream_timer_active) or stream_failed: return False if json_mode: return True with stream_lock: pending = "".join(stream_buffer) if not pending: return True chunk = pending[:STREAM_CHARS_PER_FLUSH] rest = pending[STREAM_CHARS_PER_FLUSH:] stream_buffer.clear() if rest: stream_buffer.append(rest) if chunk: GLib.idle_add( self.update_stream_text, chunk, chat_path_for_ai ) return True GLib.timeout_add(STREAM_FLUSH_MS, flush_stream_buffer) # proc çalıştığı sürece stdout/stderr hazır oldukça oku while True: rlist, _, _ = select.select([out_fd, err_fd], [], [], 0.1) if out_fd in rlist: data = os.read(out_fd, 4096) if data: text = decoder.decode(data) if text: partial += text stripped = partial.lstrip() # JSON/file output ise stream göstermiyoruz. # Çünkü file_create / generated_files gibi çıktılar UI'de parça parça görünmemeli. if ( stripped.startswith('{"type":') or stripped.startswith('{"content":') or stripped.startswith('{"generated_files":') or stripped.startswith('file_create') ): json_mode = True if not json_mode: with stream_lock: stream_buffer.append(text) if err_fd in rlist: 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 # decoder tail tail = decoder.decode(b"", final=True) if tail: partial += tail if not json_mode: with stream_lock: stream_buffer.append(tail) # Son kalan buffer'ı hemen bas if not json_mode: with stream_lock: if stream_buffer: final_chunk = "".join(stream_buffer) stream_buffer.clear() else: final_chunk = "" if final_chunk: GLib.idle_add( self.update_stream_text, final_chunk, chat_path_for_ai ) def stop_stream_when_empty(): nonlocal stream_timer_active with stream_lock: empty = not bool(stream_buffer) if empty: stream_timer_active = False return False return True GLib.timeout_add(STREAM_FLUSH_MS, stop_stream_when_empty) proc.wait() rc = proc.returncode self.ai_procs.pop(key, None) stderr_text = "".join(stderr_chunks).strip() if key in self.ai_stop_requested_chats: stream_failed = True stream_timer_active = False with stream_lock: stream_buffer.clear() GLib.idle_add(self.after_ai_response, chat_path_for_ai) return if rc != 0: stream_failed = True stream_timer_active = False with stream_lock: stream_buffer.clear() if key in self.ai_stop_requested_chats: GLib.idle_add(self.after_ai_response, chat_path_for_ai) return GLib.idle_add( self.handle_ai_error, stderr_text or "AI process failed.", chat_path_for_ai ) return else: GLib.idle_add(self.finalize_ai_response, partial, chat_path_for_ai) if approved_web_search_query: GLib.idle_add( self.mark_latest_web_search_done, chat_path_for_ai, True, "" ) GLib.idle_add(self.after_ai_response, chat_path_for_ai) except Exception as e: try: stream_timer_active = False stream_failed = True with stream_lock: stream_buffer.clear() except Exception: pass if approved_web_search_query: GLib.idle_add( self.mark_latest_web_search_done, chat_path_for_ai, False, str(e) ) GLib.idle_add( self.handle_ai_error, str(e), chat_path_for_ai ) if __name__ == "__main__": image_arg = None if len(sys.argv) >= 2: image_arg = sys.argv[1] app = ChatApp(image_arg) app.run()