#!/usr/bin/env python3 import gi 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 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" AI_SCRIPT = str(Path.home() / "capture-ai/ai.py") GENERATED_DIR = BASE_DIR / "generated_images" GENERATED_DIR.mkdir(parents=True, exist_ok=True) CHAT_DIR.mkdir(parents=True, exist_ok=True) # Config dosyasından son açılan chat'i al, yoksa default.json kullan if CONFIG_PATH.exists(): with open(CONFIG_PATH, "r", encoding="utf-8") as f: config = json.load(f) or {} last_chat_name = config.get("last_chat", "default.json") dark_mode = config.get("dark_mode", True) else: config = {} last_chat_name = "default.json" dark_mode = True DEFAULT_CHAT = CHAT_DIR / last_chat_name if not DEFAULT_CHAT.exists(): DEFAULT_CHAT.write_text("[]", encoding="utf-8") 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", } 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", } 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.current_chat = DEFAULT_CHAT self.image_path = image_path self.pending_images = [] self.pending_files = [] 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.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 # ---------------- 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 ensure_ui_i18n_config(self): cfg = self.load_config() changed = False if not isinstance(cfg.get("ui_language"), str) or not str(cfg.get("ui_language")).strip(): cfg["ui_language"] = "tr" changed = True if not isinstance(cfg.get("ui_strings"), dict): cfg["ui_strings"] = {} changed = True if changed: self.save_config(cfg) def get_ui_language(self) -> str: cfg = self.load_config() lang = str(cfg.get("ui_language", "tr") or "tr").strip().lower() return lang if lang else "tr" def get_ui_strings_map(self) -> dict: cfg = self.load_config() raw = cfg.get("ui_strings", {}) return raw if isinstance(raw, dict) else {} def get_ui_lang_map(self, lang: str | None = None) -> dict: if lang is None: lang = self.get_ui_language() all_strings = self.get_ui_strings_map() selected = all_strings.get(lang, {}) if not isinstance(selected, dict): selected = {} return selected def __call__(self, key: str) -> str: lang_map = self.get_ui_lang_map() return str(lang_map.get(key, key)) 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) def get_available_ui_languages(self) -> list[str]: strings = self.get_ui_strings_map() if not isinstance(strings, dict): return [] out = [] for k in strings.keys(): ks = str(k).strip() if ks: out.append(ks) return out def _get_active_colors_key(self) -> str: cfg = self.load_config() is_dark = bool(cfg.get("dark_mode", True)) 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 = self.load_config() is_dark = bool(cfg.get("dark_mode", True)) base = DEFAULT_CUSTOM_COLORS_DARK if is_dark else DEFAULT_CUSTOM_COLORS_LIGHT return dict(base) def _get_theme_colors(self) -> dict: cfg = self.load_config() is_dark = bool(cfg.get("dark_mode", True)) key = "custom_colors_dark" if is_dark else "custom_colors_light" raw = cfg.get(key, {}) if not isinstance(raw, dict): raw = {} 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 = self.load_config() is_dark = bool(cfg.get("dark_mode", True)) 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 _get_models_list(self, cfg: dict): models = cfg.get("ai_models", []) if not isinstance(models, list): models = [] # normalize models = [str(m).strip() for m in models if str(m).strip()] return models def _ensure_min_one_model(self, cfg: dict): """ Senin isteğin: minimum 1 model kalmalı. Eğer config boşsa burada bir default ekliyoruz. İstersen bunu hata yapacak şekilde değiştirebilirsin. """ models = self._get_models_list(cfg) if not models: models = ["google/gemini-2.5-flash-image"] 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]) def get_chat_model(self, chat_path: Path) -> str: """ Bu chat için model: - cfg.chat_models[chat_name] varsa ve ai_models içinde ise onu döndür - yoksa / silinmişse => ai_models[0] + config’e yaz (self-heal) """ cfg = self.load_config() models = self._ensure_min_one_model(cfg) chat_models = cfg.get("chat_models", {}) if not isinstance(chat_models, dict): chat_models = {} key = chat_path.name m = str(chat_models.get(key) or "").strip() if (not m) or (m not in models): m = str(models[0]) chat_models[key] = m cfg["chat_models"] = chat_models self.save_config(cfg) return m def set_chat_model(self, chat_path: Path, model_id: str): """ Bu chat’in modelini set eder + LRU (en son seçilen model en üste) """ model_id = str(model_id).strip() if not model_id: return cfg = self.load_config() models = self._ensure_min_one_model(cfg) # LRU: seçileni en üste al if model_id not in models: models.insert(0, model_id) else: models = [m for m in models if m != model_id] models.insert(0, model_id) cfg["ai_models"] = models chat_models = cfg.get("chat_models", {}) if not isinstance(chat_models, dict): chat_models = {} chat_models[chat_path.name] = model_id cfg["chat_models"] = chat_models # selected_model artık gereksiz; istersen yazma: # cfg["selected_model"] = model_id self.save_config(cfg) # UI state self.active_model = model_id 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.ensure_color_configs() self.ensure_ui_i18n_config() self.ensure_audio_input_config() 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) # 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) # 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) # 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, 110) 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) 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) # --- 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.textview.set_top_margin(4) self.textview.set_bottom_margin(4) self.textview.set_left_margin(2) self.textview.set_right_margin(2) 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 # 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_bottom(150) # preview+input üstünde dursun 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.win.present() GLib.idle_add(self.force_focus_entry) # ---------------- 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"] 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; }} .user-bubble {{ background: {user_bg}; color: {user_text}; border-radius: 12px; padding: 10px; margin-right: 10px; }} .bot-bubble {{ background: {bot_bg}; color: {bot_text}; border-radius: 12px; padding: 10px; margin-left: 10px; }} .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); }} .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: 2px solid #ff9800; }} .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 = self.load_config() current = bool(cfg.get("dark_mode", True)) new_value = not current cfg["dark_mode"] = new_value self.save_config(cfg) self.apply_theme(new_value) self.refresh_visual_theme_only() # ---------------- INPUT ---------------- 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 finalize_ai_response(self, raw_text: str): raw_text = (raw_text or "").strip() try: data = json.loads(raw_text) except Exception: data = {"type": "text", "content": raw_text} try: with open(self.current_chat, "r", encoding="utf-8") as f: messages = json.load(f) or [] except Exception: messages = [] # son streaming bot placeholder'ını bul bot_idx = None for i in range(len(messages) - 1, -1, -1): msg = messages[i] if msg.get("role") == "bot" and msg.get("streaming"): bot_idx = i break if bot_idx is None: messages.append({"role": "bot", "content": ""}) bot_idx = len(messages) - 1 bot_msg = messages[bot_idx] bot_msg.pop("streaming", None) result_type = str(data.get("type") or "text").strip().lower() bot_msg["content"] = str(data.get("content") or "").strip() saved_images = [] seen_saved = set() # usage varsa kaydet usage = data.get("usage") if isinstance(usage, dict): bot_msg["usage"] = usage else: bot_msg.pop("usage", None) # 1) URL tabanlı görseller image_candidates = [] inline_data_urls = [] # tekil alanlar for key in ("image", "url"): val = data.get(key) if isinstance(val, str) and val.strip(): sval = val.strip() if sval.startswith("data:image/"): inline_data_urls.append(sval) else: image_candidates.append(sval) imgs = data.get("images") if isinstance(imgs, list): for item in imgs: if isinstance(item, str) and item.strip(): sval = item.strip() if sval.startswith("data:image/"): inline_data_urls.append(sval) else: image_candidates.append(sval) elif isinstance(item, dict): u = item.get("url") if isinstance(u, str) and u.strip(): sval = u.strip() if sval.startswith("data:image/"): inline_data_urls.append(sval) else: image_candidates.append(sval) iu = item.get("image_url") if isinstance(iu, str) and iu.strip(): sval = iu.strip() if sval.startswith("data:image/"): inline_data_urls.append(sval) else: image_candidates.append(sval) elif isinstance(iu, dict): uu = iu.get("url") if isinstance(uu, str) and uu.strip(): sval = uu.strip() if sval.startswith("data:image/"): inline_data_urls.append(sval) else: image_candidates.append(sval) for url in image_candidates: local_path = self.download_image_to_cache(url) if local_path and local_path not in seen_saved: seen_saved.add(local_path) saved_images.append(local_path) # 2) base64 tabanlı görseller base64_candidates = [] for item in inline_data_urls: if item.startswith("data:image/") and "," in item: base64_candidates.append(item.split(",", 1)[1].strip()) one_b64 = data.get("image_base64") if isinstance(one_b64, str) and one_b64.strip(): base64_candidates.append(one_b64.strip()) many_b64 = data.get("images_base64") if isinstance(many_b64, list): for item in many_b64: if isinstance(item, str) and item.strip(): base64_candidates.append(item.strip()) elif isinstance(item, dict): b = item.get("b64_json") if isinstance(b, str) and b.strip(): base64_candidates.append(b.strip()) for item in base64_candidates: if item.startswith("data:image/") and "," in item: item = item.split(",", 1)[1].strip() local_path = self.save_base64_image(item, "png") if local_path and local_path not in seen_saved: seen_saved.add(local_path) saved_images.append(local_path) if saved_images: bot_msg["images"] = saved_images else: bot_msg.pop("images", None) messages[bot_idx] = bot_msg try: with open(self.current_chat, "w", encoding="utf-8") as f: json.dump(messages, f, ensure_ascii=False, indent=2) except Exception as e: print("finalize_ai_response write error:", e) # ---------------- IMAGE,DOCS PREVIEW AND ATTACH MENU ---------------- def 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, f"Script bulunamadı: {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, f"Fotoğraf çekme hatası: {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 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): self.typing_active = False self.stream_active = False self.ai_proc = None self.set_generating_state(False) try: self.typing_row.set_visible(False) except Exception: pass try: with open(self.current_chat, "r", encoding="utf-8") as f: messages = json.load(f) or [] cfg = self.load_config() show_usage = bool(cfg.get("show_usage", False)) except Exception: messages = [] messages.append({"role": "bot", "content": f"❌ Hata:\n{error_text}"}) try: with open(self.current_chat, "w", encoding="utf-8") as f: json.dump(messages, f, ensure_ascii=False, indent=2) except Exception: pass self.load_chat() self.scroll_to_bottom() def animate_typing(self): if not getattr(self, "typing_active", False): return False # stream başlayınca noktaları durdur if getattr(self, "stream_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): self.stream_active = False self.typing_active = True self.typing_label.set_text(f"{self('o_Thinking')}...") self.typing_row.set_visible(True) self.typing_dots = 0 GLib.timeout_add(500, self.animate_typing) def hide_typing_indicator(self): self.typing_active = False self.stream_active = False try: self.typing_row.set_visible(False) except Exception: pass def after_ai_response(self): self.hide_typing_indicator() self.set_generating_state(False) if getattr(self, "ai_stop_requested", False): try: with open(self.current_chat, "r", encoding="utf-8") as f: messages = json.load(f) or [] messages.append({ "role": "info", "content": self("o_Cancelled") }) with open(self.current_chat, "w", encoding="utf-8") as f: json.dump(messages, f, ensure_ascii=False, indent=2) except Exception: pass self.ai_proc = None self.ai_stop_requested = False self.streaming_row = None self.streaming_label = None self.load_chat() self.scroll_to_bottom(force=True) # ---------------- MODELS LIST ---------------- def apply_models_visibility(self): if not getattr(self, "sidebar_expanded", True): self.models_header.set_visible(False) self.models_list.set_visible(False) return 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 self.set_chat_model(self.current_chat, model_id) def toggle_models_list(self, *_): self.models_list_open = not self.models_list_open self.apply_models_visibility() def confirm_delete_model(self, button, model_id: str): cfg = self.load_config() models = self._ensure_min_one_model(cfg) # 1 model kaldıysa sildirme 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 = 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 != model_id] cfg2["ai_models"] = models2 # silinen model chatlerde kullanılıyorsa default’a çek chat_models = cfg2.get("chat_models", {}) if not isinstance(chat_models, dict): chat_models = {} new_default = str(models2[0]) if models2 else None for k, v in list(chat_models.items()): if v == model_id: chat_models[k] = new_default cfg2["chat_models"] = chat_models self.save_config(cfg2) # aktif chat modeli self-heal 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) # 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: # mevcut set_chat_model LRU mantığıyla listeye ekliyor ve bu chat’e atıyor self.set_chat_model(self.current_chat, model_id) 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 = self.load_config() models = self._ensure_min_one_model(cfg) active = self.get_chat_model(self.current_chat) self.active_model = active for model_id in models: model_id = str(model_id) row = Gtk.ListBoxRow() row.model_id = model_id # <-- önemli 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) 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 = self.load_config() plist = cfg.get("pinned_chats", []) if isinstance(plist, list) and old_path.name in plist: cfg["pinned_chats"] = [new_path.name if x == old_path.name else x for x in plist] if cfg.get("last_chat") == old_path.name: cfg["last_chat"] = new_path.name chat_models = cfg.get("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 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 = self.load_config() pinned = cfg.get("pinned_chats", []) if not isinstance(pinned, list): pinned = [] 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_ai_settings_dialog(self): cfg = 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(bool(cfg.get("force_ui_language", False))) 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) # --- 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(bool(cfg.get("is_mic_online", True))) 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(bool(cfg.get("use_desktop_voice", False))) 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.get("stt_model_online", "openai/gpt-audio-mini") or "")) 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("/usr/bin/whisper-cli") whisper_bin_entry.set_text(str(cfg.get("whisper_cpp_bin", "") or "")) 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/models/ggml-base.bin") whisper_model_entry.set_text(str(cfg.get("whisper_cpp_model", "") or "")) 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 = self.load_config() cfg2["force_ui_language"] = bool(response_switch.get_active()) 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 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 = self.load_config() return str(cfg.get("chat_bg_image", "") or "").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 ensure_color_configs(self): cfg = self.load_config() changed = False if not isinstance(cfg.get("custom_colors_dark"), dict): cfg["custom_colors_dark"] = dict(DEFAULT_CUSTOM_COLORS_DARK) changed = True if not isinstance(cfg.get("custom_colors_light"), dict): cfg["custom_colors_light"] = dict(DEFAULT_CUSTOM_COLORS_LIGHT) changed = True if "chat_bg_image" not in cfg: cfg["chat_bg_image"] = "" changed = True if "show_usage" not in cfg: cfg["show_usage"] = True changed = True if changed: self.save_config(cfg) def refresh_visual_theme_only(self): self.apply_css() self.apply_theme(self.load_config().get("dark_mode", True)) try: self.win.queue_draw() except Exception: pass for w in [ getattr(self, "win", None), getattr(self, "sidebar", None), getattr(self, "scroll", None), getattr(self, "content_box", None), getattr(self, "chat_box", None), getattr(self, "textview", None), getattr(self, "text_scroll", None), getattr(self, "preview_row", None), getattr(self, "preview_hbox", None), getattr(self, "selection_row", None), ]: if w is not None: try: w.queue_draw() except Exception: pass 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 = self.load_config() is_dark = bool(cfg.get("dark_mode", True)) 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") # --- 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(bool(cfg.get("show_usage", True))) 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) 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("") 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()) 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(8) 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 = self.load_config() entry.set_text(cfg.get("response_style", "") or "") 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): dialog = Gtk.Dialog() dialog.set_transient_for(self.win) dialog.set_modal(True) self.bind_i18n(dialog, "title", "o_OpenRouter_API_Key") content = dialog.get_content_area() label = Gtk.Label() self.bind_i18n(label, "label", "o_OpenRouter_API_Key") label.set_xalign(0) content.append(label) entry = Gtk.Entry() self.bind_i18n(entry, "placeholder", "o_OpenRouter_Placeholder") entry.set_visibility(False) content.append(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: new_key = entry.get_text().strip() if new_key: cfg = self.load_config() cfg["open_router_key"] = new_key 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_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) 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) 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 = self.load_config() pinned = cfg.get("pinned_chats", []) if not isinstance(pinned, list): pinned = [] 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 = self.load_config() pinned = cfg.get("pinned_chats", []) if isinstance(pinned, list) and file_path.name in pinned: cfg["pinned_chats"] = [x for x in pinned if x != file_path.name] # chat_models cleanup chat_models = cfg.get("chat_models", {}) if isinstance(chat_models, dict) and file_path.name in chat_models: del chat_models[file_path.name] cfg["chat_models"] = chat_models if cfg.get("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("[]", 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: with open(self.current_chat, "r", encoding="utf-8") as f: messages = json.load(f) or [] except Exception: return if not (0 <= idx < len(messages)): return content = (messages[idx].get("content") or "") if not content: return inner = self.extract_copy_block(content) self.copy_to_clipboard(inner if inner is not None else content) def regenerate_by_index(self, idx: int): # 1) Chat'i oku try: with open(self.current_chat, "r", encoding="utf-8") as f: messages = json.load(f) or [] 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 = (messages[idx].get("content") or "").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) try: with open(self.current_chat, "w", encoding="utf-8") as f: json.dump(messages, f, ensure_ascii=False, indent=2) 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 threading.Thread(target=self.call_ai, args=(selected_arg,), 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")) 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 path.write_text("[]", encoding="utf-8") self.current_chat = path cfg = self.load_config() cfg["last_chat"] = path.name # yeni chat default model alır models = self._ensure_min_one_model(cfg) chat_models = cfg.get("chat_models", {}) if not isinstance(chat_models, dict): chat_models = {} chat_models[path.name] = str(models[0]) cfg["chat_models"] = chat_models 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() 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.current_chat = Path(chat_path) 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.auto_focus_enabled = True GLib.idle_add(self.force_focus_entry) # ---------------- CHAT LOAD / SELECTION ---------------- def clear_selection(self, *_): # tüm seçimleri kaldır self.selected_indexes.clear() # seçim modunu kapat self.selection_mode = False # label güncelle + checkboxları kaldırmak için chat’i refresh et self.update_selection_label() self.load_chat() def load_chat(self): # 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) with open(self.current_chat, "r", encoding="utf-8") as f: messages = json.load(f) or [] cfg = self.load_config() show_usage = bool(cfg.get("show_usage", False)) for i, msg in enumerate(messages): row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) row.set_hexpand(False) bubble = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) bubble.set_hexpand(False) # 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) # Click selection (eski özellik) click = Gtk.GestureClick() click.connect("pressed", lambda g, n, x, y, idx=i: self.on_message_clicked(idx)) bubble.add_controller(click) # 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) if "content" in msg: content = msg.get("content") or "" 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: # 🔹 Tek renk kod bloğu container (Title + Kopyala + Kod aynı alan) code_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) code_container.add_css_class("code-block") # 🔹 Üst satır: Title solda, Kopyala sağda top_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) top_row.set_hexpand(True) 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) top_row.append(title_label) spacer = Gtk.Box() spacer.set_hexpand(True) top_row.append(spacer) 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_halign(Gtk.Align.END) copy_btn.set_focusable(False) self._consume_click(copy_btn) copy_btn.connect("clicked", lambda b, t=copy_block: self.copy_to_clipboard(t)) top_row.append(copy_btn) # 🔹 Kod label 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_width_chars(0) code_label.set_max_width_chars(60) 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_wrap(True) label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) label.set_xalign(0) label.set_width_chars(0) label.set_max_width_chars(60) label.set_hexpand(False) 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 # içerik henüz yoksa boş balonu gösterme if 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 chip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) chip.add_css_class("refs-preview") icon = Gtk.Image.new_from_icon_name("text-x-generic-symbolic") 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) 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.END) bubble.set_halign(Gtk.Align.END) else: bubble.add_css_class("bot-bubble") row.set_halign(Gtk.Align.START) if i in self.selected_indexes: 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 = f"❌ APPLY FAILED: {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) # 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) # Tek seçili balonda action butonları (copy + regenerate) show_actions = (len(self.selected_indexes) == 1 and i in self.selected_indexes) # bubble + buton alanı (spacer) content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) btn_space = Gtk.Box() btn_space.set_size_request(130, -1) # buton alanı (70-120 ayarla) if msg.get("role") == "user": # user sağda → butonlar SOLDA: [space][bubble] content.append(btn_space) content.append(bubble) else: # bot solda → butonlar SAĞDA: [bubble][space] content.append(bubble) content.append(btn_space) wrapper = Gtk.Overlay() wrapper.set_child(content) if show_actions: actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) copy_btn = Gtk.Button(label="📋") self._consume_click(copy_btn) copy_btn.connect("clicked", lambda _b, idx=i: self.copy_message_by_index(idx)) regen_btn = Gtk.Button(label="♻") 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) actions.set_valign(Gtk.Align.CENTER) # BUTONLAR spacer tarafına yaslanacak: if msg.get("role") == "user": actions.set_halign(Gtk.Align.START) # sol spacer içinde kalsın actions.set_margin_start(0) else: actions.set_halign(Gtk.Align.END) # sağ spacer içinde kalsın actions.set_margin_end(0) wrapper.add_overlay(actions) row.append(wrapper) self.chat_box.append(row) # AI düşünüyorsa typing satırını en alta geri ekle if getattr(self, "typing_active", False): try: if self.typing_row.get_parent() is not self.content_box: parent = self.typing_row.get_parent() if parent is not None: parent.remove(self.typing_row) self.content_box.append(self.typing_row) except Exception: pass GLib.idle_add(self.scroll_to_bottom) def on_message_clicked(self, index): self.auto_scroll_enabled = False 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() 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: h = self.preview_row.get_allocated_height() if self.preview_row.get_visible() else 0 # input + padding için kaba bir ek self.scroll_btn.set_margin_bottom(120 + h) 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): # en altta mı kontrol et at_bottom = adjustment.get_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 ensure_audio_input_config(self): cfg = self.load_config() changed = False if "force_ui_language" not in cfg: cfg["force_ui_language"] = False changed = True if "is_mic_online" not in cfg: cfg["is_mic_online"] = True changed = True if "use_desktop_voice" not in cfg: cfg["use_desktop_voice"] = False changed = True if "stt_model_online" not in cfg: cfg["stt_model_online"] = "openai/gpt-audio-mini" changed = True if "whisper_cpp_bin" not in cfg: cfg["whisper_cpp_bin"] = "/home/$USER/whisper.cpp/build/bin/whisper-cli" changed = True if "whisper_cpp_model" not in cfg: cfg["whisper_cpp_model"] = "/home/$USER/.local/share/whisper/ggml-tiny.bin" changed = True if changed: self.save_config(cfg) def _get_use_desktop_voice(self) -> bool: cfg = self.load_config() return bool(cfg.get("use_desktop_voice", False)) 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("Masaüstü sesi için monitor source bulunamadı.") return else: target = self._detect_pw_mic_target() if not target: self.handle_ai_error("Mikrofon kaynağı bulunamadı.") 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("Masaüstü sesi kaydı için pw-record gerekli.") return device = self._detect_arecord_mic_device() if not device: self.handle_ai_error("Mikrofon cihazı bulunamadı.") return cmd = [ar, "-D", str(device), "-f", "S16_LE", "-r", "16000", "-c", "1", str(self._last_wav)] else: self.handle_ai_error("pw-record veya arecord bulunamadı.") 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(f"Ses kaydı başlatılamadı: {e}") return self.mic_btn.set_label("⏹") self.mic_btn.set_tooltip_text("Kaydı durdur") 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 = self.load_config() is_online = bool(cfg.get("is_mic_online", True)) 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, f"STT hata: {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, "Ses algılanamadı.") 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("open_router_key yok") 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(f"whisper_cpp_bin bulunamadı: {bin_path}") if not model_path or not Path(model_path).exists(): raise RuntimeError(f"whisper_cpp_model bulunamadı: {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 "whisper.cpp 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 = "unknown error" buf = self.textview.get_buffer() # input'u temizle ve hata bas (istersen temizleme, sadece append de yapabilirsin) buf.set_text(f"❌ APPLY FAILED: {err}") self.textview.grab_focus() 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, f"file not found: {p}" original = p.read_text(encoding="utf-8", errors="ignore") idx = original.find(find) if idx < 0: return False, f"find failed: anchor not found in {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, f"unknown mode: {mode}" if new_content == original: return False, "no changes produced (maybe identical content?)" p.write_text(new_content, encoding="utf-8") return True, "" except Exception as e: return False, f"exception: {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, "no ops to apply" 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 = ""): """ status: "ok" | "fail" | "cancel" """ try: with open(self.current_chat, "r", encoding="utf-8") as f: messages = json.load(f) or [] except Exception: return if not (0 <= msg_index < len(messages)): return messages[msg_index]["apply_status"] = { "status": status, "err": (err or "").strip() } try: with open(self.current_chat, "w", encoding="utf-8") as f: json.dump(messages, f, ensure_ascii=False, indent=2) except Exception: return # ---------------- STOP GENERATE ----------------- def stop_ai_generation(self): self.ai_stop_requested = True proc = getattr(self, "ai_proc", None) if proc is not None: try: proc.terminate() except Exception: try: proc.kill() except Exception: pass self.typing_active = False self.stream_active = False try: self.typing_row.set_visible(False) except Exception: pass self.set_generating_state(False) # Eğer streaming placeholder boş kaldıysa temizle try: with open(self.current_chat, "r", encoding="utf-8") as f: messages = json.load(f) or [] changed = False if messages and isinstance(messages[-1], dict): last = messages[-1] # Son mesaj boş streaming bot placeholder ise sil if last.get("role") in ("bot", "assistant") and last.get("streaming"): content = (last.get("content") or "").strip() if not content: messages.pop() changed = True # Cancel bilgisini son user mesajına yaz for j in range(len(messages) - 1, -1, -1): msg = messages[j] if msg.get("role") == "user": msg["gen_status"] = {"status": "cancel", "err": ""} changed = True break if changed: with open(self.current_chat, "w", encoding="utf-8") as f: json.dump(messages, f, ensure_ascii=False, indent=2) except Exception: pass self.load_chat() self.streaming_row = None self.streaming_label = None self.scroll_to_bottom(force=True) def on_send_button_clicked(self, *_): if getattr(self, "is_generating", False): self.stop_ai_generation() else: self.send_message(None) def set_generating_state(self, generating: bool): self.is_generating = bool(generating) if hasattr(self, "send_btn") and self.send_btn: if self.is_generating: 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 with open(self.current_chat, "r", encoding="utf-8") as f: messages = json.load(f) or [] 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 messages.append(new_message) # Streaming için placeholder bot mesajı ekle (UI bu balonu canlı günceller) messages.append({"role": "bot", "content": "", "streaming": True}) self._streaming_bot_idx = len(messages) - 1 with open(self.current_chat, "w", encoding="utf-8") as f: json.dump(messages, f, ensure_ascii=False, indent=2) 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() self.ai_stop_requested = False self.set_generating_state(True) threading.Thread(target=self.call_ai, args=(selected_arg,), 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" # TR cümle istersen: # "Bu satırı replace ediyorum: -> " return f'Bu satırı {mode} ediyorum: {self._short(find)} -> {self._short(text)}' def update_stream_text(self, text: str): text = fix_mojibake(text) self.stream_active = True try: self.typing_row.set_visible(False) except Exception: pass lab = getattr(self, "streaming_label", None) row = getattr(self, "streaming_row", None) if lab is not None: if row is not None and not row.get_visible(): row.set_visible(True) current = lab.get_text() or "" lab.set_text(current + text) else: try: self.typing_label.set_text(text) self.typing_row.set_visible(True) except Exception: pass self.scroll_to_bottom(force=True) 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 = Gdk.Texture.new_from_filename(str(p)) pic.set_paintable(texture) except Exception as e: print("image preview load error:", e) fallback = Gtk.Label(label="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=f"Image could not be loaded:\n{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): cmd = [sys.executable, "-u", AI_SCRIPT, str(self.current_chat)] if selected_arg: cmd.append(selected_arg) 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 ) self.ai_proc = 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 = "" last_len = 0 last_push = 0 stderr_chunks = [] json_mode = False out_fd = proc.stdout.fileno() err_fd = proc.stderr.fileno() # 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 # Gelen çıktı JSON gibi mi? stripped = partial.lstrip() if stripped.startswith("{") or stripped.startswith("["): json_mode = True # daha sık güncelle (stream hissi) now = GLib.get_monotonic_time() # mikro-saniye if (now - last_push) > 8_000: # 8ms last_push = now new_part = partial[last_len:] last_len = len(partial) # Sadece düz metinse ekrana akıt if new_part and not json_mode: GLib.idle_add(self.update_stream_text, new_part) if err_fd in rlist: 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 new_part = partial[last_len:] last_len = len(partial) if new_part and not json_mode: GLib.idle_add(self.update_stream_text, new_part) proc.wait() rc = proc.returncode self.ai_proc = None stderr_text = "".join(stderr_chunks).strip() if self.ai_stop_requested: GLib.idle_add(self.after_ai_response) return if rc != 0: err = stderr_text or partial.strip() or "Bilinmeyen hata" GLib.idle_add(self.handle_ai_error, err) else: GLib.idle_add(self.finalize_ai_response, partial) GLib.idle_add(self.after_ai_response) except Exception as e: self.ai_proc = None if self.ai_stop_requested: GLib.idle_add(self.after_ai_response) else: GLib.idle_add(self.handle_ai_error, str(e)) if __name__ == "__main__": image_arg = None if len(sys.argv) >= 2: image_arg = sys.argv[1] app = ChatApp(image_arg) app.run()