Files
capture-ai/ui.py
T
2026-05-16 09:56:22 +03:00

8590 lines
294 KiB
Python
Raw Blame History

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