From 07e43f7da05b2f5eb291b7ae0b104421e6a41f6e Mon Sep 17 00:00:00 2001 From: FURK4NGG <105324908+FURK4NGG@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:09:40 +0300 Subject: [PATCH] Add files via upload --- ai.py | 628 +++++++++-- capture-ai.sh | 37 + ui.py | 2862 ++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 3324 insertions(+), 203 deletions(-) create mode 100644 capture-ai.sh diff --git a/ai.py b/ai.py index 359b74c..ed0eb9e 100644 --- a/ai.py +++ b/ai.py @@ -1,99 +1,565 @@ #!/usr/bin/env python3 -import os import sys +import json import base64 +import mimetypes +import base64 +import shutil +import argparse +from pathlib import Path +from datetime import datetime, timezone + import requests -import time -# --- DEFAULT FALLBACK MODELS --- -DEFAULT_MODELS = [ - "qwen/qwen-2.5-vl-7b-instruct:free", - "meta-llama/llama-3.2-11b-vision-instruct", - "google/gemini-2.5-flash-image", -] +CONFIG_PATH = Path.home() / ".config" / "capture-ai" / "config.json" +CACHE_BASE = Path.home() / ".cache" / "capture-ai" +REFS_CACHE_DIR = CACHE_BASE / "refs" -# --- ARGUMENTS --- -if len(sys.argv) < 3: - print("Kullanım: ai.py [model1,model2,...]") - sys.exit(1) +OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" -image_path = sys.argv[1] -prompt = sys.argv[2] +GLOBAL_SYSTEM_PROMPT = ( + "Bu konuşmada sana zaman zaman e-posta, kod, komut, yapılandırma dosyası " + "veya birebir kopyalanması gereken metinler yazdırılabilir.\n\n" -# --- MODELS FROM UI (OPTIONAL) --- -if len(sys.argv) >= 4 and sys.argv[3].strip(): - MODELS = [m.strip() for m in sys.argv[3].split(",")] -else: - MODELS = DEFAULT_MODELS + "FORMAT KURALI:\n" + "Eğer kullanıcıdan gelen istekte doğrudan kopyalanabilir bir içerik " + "(örneğin kod, script, terminal komutu, yapılandırma dosyası vb.) " + "üretmen gerekiyorsa aşağıdaki formatı kullan:\n\n" -# --- API KEY --- -API_KEY = os.environ.get("OPENROUTER_API_KEY") -if not API_KEY: - print("HATA: OPENROUTER_API_KEY not set") - sys.exit(1) + "title\n" + "\n" + "title\n" + "copy\n" + "\n" + "copy\n\n" -# --- IMAGE LOAD --- -if not os.path.exists(image_path): - print(f"HATA: Resim bulunamadı → {image_path}") - sys.exit(1) + "Kurallar:\n" + "- 'title' ve 'copy' kelimeleri tek başına satır olmalıdır.\n" + "- 'title' bloğu kod bloğundan önce gelmelidir.\n" + "- Kod yoksa bu formatı kullanma.\n" + "- Normal sohbet cevaplarında bu formatı kullanma.\n" + "- Sadece gerçekten kopyalanabilir içerik varsa bu formatı kullan.\n" -with open(image_path, "rb") as f: - image_b64 = base64.b64encode(f.read()).decode("utf-8") + "\n\n[FILE] BLOKLARI HAKKINDA:\n" + "- Eğer kullanıcı bir dosya eklediyse, dosya içeriği bu mesaj içinde " + "[FILE] ... [CONTENT] ... [/CONTENT] formatında ZATEN verilmiştir.\n" + "- Bu durumda kullanıcıdan 'kodu buraya yapıştır' veya 'dosyayı paylaş' deme.\n" + "- Değişiklik gerekiyorsa [CONTENT] içeriğini okuyarak işlem yap.\n" + "- Dosya içeriğini tekrar isteme.\n" + "- path alanı olarak [FILE] bloğunda verilen path değerini aynen kullan.\n" -# --- REQUEST CONFIG --- -URL = "https://openrouter.ai/api/v1/chat/completions" + "\n\nDOSYA DEĞİŞİKLİĞİ KURALI:\n" + "- [FILE] bloklarında 'editable: true' ise dosyada değişiklik önerebilirsin.\n" + "- 'editable: false' olan dosyalar için ASLA dosyaya uygulanacak değişiklik isteme.\n" + "- editable:false dosyalar için apply bloğu üretme.\n" + "- Eğer değişiklik gerekmiyorsa apply bloğu hiç koyma.\n" -HEADERS = { - "Authorization": f"Bearer {API_KEY}", - "Content-Type": "application/json", - "HTTP-Referer": "http://localhost", - "X-Title": "ai-capture", -} + "\n\nAPPLY (OTOMATİK UYGULAMA) PROTOKOLÜ:\n" + "Eğer ve sadece eğer kullanıcı 'editable: true' bir dosyada değişiklik istiyorsa,\n" + "cevabının SONUNA aşağıdaki blok formatıyla ekle (başka yerde kullanma):\n\n" -# --- MODEL TRY FUNCTION --- -def try_model(model_name): - payload = { - "model": model_name, - "messages": [ - { - "role": "user", - "content": [ - {"type": "text", "text": prompt}, - { - "type": "image_url", - "image_url": { - "url": f"data:image/png;base64,{image_b64}" - } - } - ], - } - ], - "max_tokens": 512, + "apply\n" + "[\n" + " {\n" + " \"path\": \"/absolute/path/to/file\",\n" + " \"find\": \"DOSYADA BİREBİR GEÇEN ANCHOR METİN\",\n" + " \"mode\": \"before|after|replace\",\n" + " \"text\": \"EKLENECEK/YERİNE GEÇECEK METİN\"\n" + " }\n" + "]\n" + "apply\n\n" + + "Kurallar:\n" + "- 'apply' kelimesi tek başına satır olmalı (başlangıç ve bitiş).\n" + "- apply bloğunun içi SADECE JSON olmalı (başka açıklama koyma).\n" + "- JSON sadece object/array olmalı, yorum (//) veya trailing virgül olamaz.\n" + "- mode sadece: before, after, replace.\n" + "- path MUTLAKA absolute path olmalı.\n" + "- find, dosyada birebir bulunan kısa/orta bir anchor olmalı.\n" + "- text, eklenecek içerik ve sonunda '\\n' gerekiyorsa ekle.\n" + "\n\nAPPLY ÖZET SATIRI KURALI:\n" + "- Eğer apply bloğu üretiyorsan, 'Başarıyla yaptım' gibi kesin/bitmiş ifade ASLA kullanma.\n" + "- apply bloğundan ÖNCE sadece 1 satır özet yaz.\n" + "- Özet formatı TAM olarak şu olsun:\n" + " 'Bu satırı ediyorum: -> '\n" + " MODE: replace|before|after\n" + "- ve çok uzunsa 120 karakterde kes ve '...' ekle.\n" + "- Özet dışında ekstra açıklama yazma.\n" +) + +def _safe_read_text(path: Path, max_bytes: int = 250_000) -> str: + try: + data = path.read_bytes() + if len(data) > max_bytes: + data = data[:max_bytes] + return data.decode("utf-8", errors="replace") + except Exception as e: + return f"[Dosya okunamadı: {e}]" + + +def _is_text_file(path: Path) -> bool: + ext = path.suffix.lower() + return ext in { + ".txt", ".md", ".py", ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", + ".sh", ".bash", ".zsh", ".js", ".ts", ".tsx", ".jsx", ".css", ".html", ".xml", + ".c", ".cpp", ".h", ".hpp", ".rs", ".go", ".java", ".kt", ".cs", ".sql" } - response = requests.post( - URL, - headers=HEADERS, - json=payload, - timeout=60, - ) - - if response.status_code == 200: - data = response.json() - return data["choices"][0]["message"]["content"] - - raise Exception(f"{model_name} → {response.status_code}: {response.text}") - - -# --- MAIN LOOP --- -for model in MODELS: +def _img_path_to_data_url(p: str) -> str | None: try: - answer = try_model(model) - print(answer) - sys.exit(0) - except Exception as e: - print(f"[FAIL] {e}", file=sys.stderr) - time.sleep(1) + fp = Path(p) + if not fp.exists(): + return None + mime, _ = mimetypes.guess_type(str(fp)) + if not mime: + mime = "image/png" + b64 = base64.b64encode(fp.read_bytes()).decode("utf-8") + return f"data:{mime};base64,{b64}" + except Exception: + return None -print("HATA: Tüm modeller başarısız") -sys.exit(1) +def _die(msg: str, code: int = 1): + sys.stderr.write(msg + "\n") + sys.exit(code) + + +def _load_config() -> dict: + if not CONFIG_PATH.exists(): + _die("Config dosyası bulunamadı.", 1) + try: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f) or {} + except Exception as e: + _die(f"Config okunamadı: {e}", 1) + + +def _save_config(cfg: dict): + try: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIG_PATH.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception: + # config yazılamasa bile AI çalışsın + pass + + +def _parse_selected_indexes(arg: str) -> list[int]: + out = [] + if not arg: + return out + for x in arg.split(","): + x = x.strip() + if x.isdigit(): + out.append(int(x)) + + # uniq + stable + seen = set() + uniq = [] + for i in out: + if i not in seen: + seen.add(i) + uniq.append(i) + return uniq + + +def _mime_for(path: Path) -> str: + mime, _ = mimetypes.guess_type(str(path)) + return mime or "application/octet-stream" + + +def _data_url_for_image(path: Path) -> str: + mime = _mime_for(path) + b64 = base64.b64encode(path.read_bytes()).decode("ascii") + return f"data:{mime};base64,{b64}" + + +def _role_map(chat_role: str) -> str: + return "assistant" if chat_role == "bot" else "user" + + +def _safe_chat_key(chat_file: Path) -> str: + return chat_file.stem.replace("/", "_").replace("\\", "_") + + +def _utc_stamp() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def _copy_to_cache(src: Path, dest_dir: Path) -> Path: + dest_dir.mkdir(parents=True, exist_ok=True) + base = src.stem.replace(" ", "_") + ext = src.suffix or ".img" + dest = dest_dir / f"{base}{ext}" + if dest.exists(): + dest = dest_dir / f"{base}_{_utc_stamp()}{ext}" + shutil.copy2(src, dest) + return dest + + +def _build_blocks_and_cache_info(msg: dict, cache_images_dir: Path | None): + blocks = [] + cached_image = None + + # 1) Text + txt = msg.get("content") or "" + if txt: + blocks.append({"type": "text", "text": txt}) + + # 2) Images (legacy "image" + new "images") + imgs = [] + one = msg.get("image") + if one: + imgs.append(one) + + many = msg.get("images") + if isinstance(many, list): + imgs.extend([str(x) for x in many if x]) + + # uniq + seen = set() + uniq_imgs = [] + for p in imgs: + if p not in seen: + seen.add(p) + uniq_imgs.append(p) + + for img_raw in uniq_imgs: + img_path = Path(img_raw) + if not img_path.exists(): + continue + + if cache_images_dir is not None: + try: + cached_image = _copy_to_cache(img_path, cache_images_dir) + except Exception: + cached_image = cached_image # önceki varsa kalsın + + try: + blocks.append({ + "type": "image_url", + "image_url": {"url": _data_url_for_image(img_path)} + }) + except Exception: + pass + + # 3) Files (new: "files": [{"path","name","edit"}]) + files = msg.get("files") or [] + if isinstance(files, list): + for f in files: + if not isinstance(f, dict): + continue + + p_raw = str(f.get("path") or "").strip() + if not p_raw: + continue + p = Path(p_raw) + if not p.exists() or not p.is_file(): + continue + + editable = bool(f.get("edit")) + name = (f.get("name") or p.name or "").strip() + mime = _mime_for(p) + + if _is_text_file(p): + file_text = _safe_read_text(p) + blocks.append({ + "type": "text", + "text": ( + "\n[FILE]\n" + f"name: {name}\n" + f"path: {str(p)}\n" + f"mime: {mime}\n" + f"editable: {'true' if editable else 'false'}\n" + "type: text\n" + "[CONTENT]\n" + f"{file_text}\n" + "[/CONTENT]\n" + "[/FILE]\n" + ) + }) + else: + # Excel/PDF/DOCX vb. şu an içerik aktarmıyoruz (metadata gönderiyoruz) + blocks.append({ + "type": "text", + "text": ( + "\n[FILE]\n" + f"name: {name}\n" + f"path: {str(p)}\n" + f"mime: {mime}\n" + f"editable: {'true' if editable else 'false'}\n" + "type: binary_or_unsupported_preview\n" + "note: Bu dosya türü şu an içerik olarak gönderilmiyor.\n" + "[/FILE]\n" + ) + }) + + if not blocks: + blocks = [{"type": "text", "text": ""}] + + return blocks, (str(cached_image) if cached_image else None) + + +def _pick_model(cfg: dict, chat_file: Path, cli_model: str | None) -> str: + """ + Öncelik: + 1) CLI --model verilmişse onu kullan (alternatif) + 2) chat_models[chat_file.name] + 3) ai_models[0] (en üst = en son kullanılan) + Eğer chat'in modeli config'te yoksa/silinmişse otomatik ai_models[0]'a düzeltir. + """ + models = cfg.get("ai_models", []) + if not isinstance(models, list): + models = [] + models = [str(m).strip() for m in models if str(m).strip()] + + if not models: + _die("Config içinde ai_models boş. En az 1 model olmalı.", 1) + + if cli_model: + return str(cli_model).strip() + + chat_models = cfg.get("chat_models", {}) + if not isinstance(chat_models, dict): + chat_models = {} + + chat_key = chat_file.name + chosen = str(chat_models.get(chat_key) or "").strip() + + if (not chosen) or (chosen not in models): + chosen = models[0] + chat_models[chat_key] = chosen + cfg["chat_models"] = chat_models + _save_config(cfg) + + return chosen + + +def main(): + # Args + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("chat_file", nargs="?") + parser.add_argument("selected_indexes", nargs="?") + parser.add_argument("--model", dest="model", default=None) + + if len(sys.argv) < 2: + sys.exit(1) + + args, _unknown = parser.parse_known_args() + + if not args.chat_file: + sys.exit(1) + + chat_file = Path(args.chat_file) + if not chat_file.exists(): + sys.exit(1) + + selected_indexes = [] + if args.selected_indexes: + try: + selected_indexes = _parse_selected_indexes(args.selected_indexes) + except Exception: + selected_indexes = [] + + # Config + API key + cfg = _load_config() + API_KEY = (cfg.get("open_router_key") or "").strip() + if not API_KEY: + _die("OpenRouter API Key bulunamadı.", 1) + + # ✅ model seçimi (chat’e göre) + model_name = _pick_model(cfg, chat_file, args.model) + + # Read chat + try: + messages = json.loads(chat_file.read_text(encoding="utf-8")) or [] + except Exception as e: + _die(f"Chat okunamadı: {e}", 1) + + if not messages: + sys.exit(0) + + last = messages[-1] + final_messages = [] + editable_map = {} # path(str) -> bool + + final_messages.append({ + "role": "system", + "content": GLOBAL_SYSTEM_PROMPT + }) + + # Cache setup + REFS_CACHE_DIR.mkdir(parents=True, exist_ok=True) + + # Reference system (TEXT + IMAGE) + CACHE + if selected_indexes: + chat_key = _safe_chat_key(chat_file) + bundle_id = _utc_stamp() + bundle_dir = REFS_CACHE_DIR / chat_key / bundle_id + bundle_images_dir = bundle_dir / "images" + bundle_dir.mkdir(parents=True, exist_ok=True) + + final_messages.append({ + "role": "system", + "content": ( + "Aşağıdaki mesajlar sadece son kullanıcı mesajını anlaman için referans olarak verilmiştir. " + "Bu mesajlara cevap verme. Sadece en son kullanıcı mesajını yanıtla." + ) + }) + + cache_items = [] + for i in selected_indexes: + if i < len(messages) - 1: + msg = messages[i] + role = _role_map(msg.get("role")) + + # editable_map doldur (referans mesajın file edit izinleri) + files_meta = msg.get("files") or [] + if isinstance(files_meta, list): + for ff in files_meta: + if not isinstance(ff, dict): + continue + p_raw = str(ff.get("path") or "").strip() + if not p_raw: + continue + editable_map[p_raw] = bool(ff.get("edit")) + + # images? + if msg.get("image") and Path(str(msg["image"])).exists(): + has_any_attach = True + imgs = msg.get("images") + if isinstance(imgs, list) and any(Path(str(p)).exists() for p in imgs): + has_any_attach = True + + # files? + files = msg.get("files") + if isinstance(files, list) and any(Path(str(f.get("path") or "")).exists() for f in files if isinstance(f, dict)): + has_any_attach = True + + if has_any_attach: + blocks, cached_img = _build_blocks_and_cache_info(msg, bundle_images_dir) + final_messages.append({"role": role, "content": blocks}) + else: + final_messages.append({"role": role, "content": msg.get("content", "")}) + cached_img = None + + cache_items.append({ + "index": i, + "role": msg.get("role", ""), + "content": msg.get("content", ""), + "image_original": msg.get("image", None), + "image_cached": cached_img + }) + + bundle = { + "bundle_id": bundle_id, + "created_at_utc": datetime.now(timezone.utc).isoformat(), + "chat_file": str(chat_file), + "selected_indexes": selected_indexes, + "items": cache_items + } + try: + (bundle_dir / "bundle.json").write_text( + json.dumps(bundle, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + except Exception: + pass + + # Last message (answer) - TEXT + (image / images) + # last message editable_map doldur + files_last = last.get("files") or [] + if isinstance(files_last, list): + for ff in files_last: + if not isinstance(ff, dict): + continue + p_raw = str(ff.get("path") or "").strip() + if not p_raw: + continue + editable_map[p_raw] = bool(ff.get("edit")) + + # Last message (answer) - TEXT + (images/files) + role = _role_map(last.get("role")) + + has_any_attach = False + + # images? + if last.get("image") and Path(str(last["image"])).exists(): + has_any_attach = True + + imgs = last.get("images") + if isinstance(imgs, list) and any(Path(str(p)).exists() for p in imgs): + has_any_attach = True + + # files? + files = last.get("files") + if isinstance(files, list) and any( + Path(str(f.get("path") or "")).exists() + for f in files + if isinstance(f, dict) + ): + has_any_attach = True + + if has_any_attach: + blocks, _ = _build_blocks_and_cache_info(last, cache_images_dir=None) + final_messages.append({"role": role, "content": blocks}) + else: + final_messages.append({"role": role, "content": last.get("content", "")}) + + # Payload + payload = { + "model": model_name, + "messages": final_messages, + "max_tokens": 5120 + } + + # Request + try: + response = requests.post( + OPENROUTER_URL, + headers={ + "Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json", + "HTTP-Referer": "http://localhost", + "X-Title": "capture-ai" + }, + json=payload, + timeout=120 + ) + + if response.status_code != 200: + sys.stderr.write(f"OpenRouter API ERROR ({response.status_code}): {response.text}\n") + sys.exit(1) + + data = response.json() + + try: + reply = data["choices"][0]["message"]["content"] + except Exception: + sys.stderr.write("OpenRouter beklenmeyen cevap formatı döndürdü.\n") + sys.exit(1) + + except requests.exceptions.Timeout: + sys.stderr.write("İstek zaman aşımına uğradı (timeout).\n") + sys.exit(1) + + except requests.exceptions.ConnectionError: + sys.stderr.write("Bağlantı hatası. İnternet bağlantınızı kontrol edin.\n") + sys.exit(1) + + except Exception as e: + sys.stderr.write(f"AI ERROR: {str(e)}\n") + sys.exit(1) + + # Save bot reply + messages.append({"role": "bot", "content": reply}) + + try: + chat_file.write_text(json.dumps(messages, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception as e: + sys.stderr.write(f"Chat yazılamadı: {e}\n") + sys.exit(1) + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/capture-ai.sh b/capture-ai.sh new file mode 100644 index 0000000..a908d04 --- /dev/null +++ b/capture-ai.sh @@ -0,0 +1,37 @@ +#!/bin/bash +source "$HOME/.config/capture-ai/env.sh" + +set -e + +BASE="$HOME/.cache/capture-ai" +IMG_DIR="$BASE/images" +CHAT_DIR="$BASE/chats" + +mkdir -p "$IMG_DIR" +mkdir -p "$CHAT_DIR" + +MODE="$1" + +case "$MODE" in + + image) + GEOM=$(slurp) || exit 0 + IMG="$IMG_DIR/capture_$(date +%s).png" + + grim -g "$GEOM" "$IMG" + + # 🔥 SADECE IMAGE GÖNDER + python "$HOME/capture-ai/ui.py" "$IMG" + ;; + + text) + python "$HOME/capture-ai/ui.py" + ;; + + *) + echo "Usage:" + echo " capture-ai.sh image" + echo " capture-ai.sh text" + ;; +esac + diff --git a/ui.py b/ui.py index b515513..bfa3d98 100644 --- a/ui.py +++ b/ui.py @@ -1,156 +1,2774 @@ -#!/usr/bin/env python3 -import sys +#!/usr/bin/env python4 import gi +import sys +import json import subprocess import threading +from pathlib import Path +import base64 +import tempfile +from datetime import datetime +import signal +import shutil gi.require_version("Gtk", "4.0") -from gi.repository import Gtk, GLib +gi.require_version("Adw", "1") +from gi.repository import Gtk, GLib, Gio, Gdk, Pango, Adw +Adw.init() -AI_SCRIPT = "/home/bob/ai_capture/ai.py" +CONFIG_PATH = Path.home() / ".config" / "capture-ai" / "config.json" +CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) -MODELS = [ - "qwen/qwen-2.5-vl-7b-instruct:free", - "meta-llama/llama-3.2-11b-vision-instruct", - "google/gemini-2.5-flash-image", -] +BASE_DIR = Path.home() / ".cache" / "capture-ai" +CHAT_DIR = BASE_DIR / "chats" +AI_SCRIPT = str(Path.home() / "capture-ai/ai.py") -class App(Gtk.Application): - def __init__(self, image_path): - super().__init__(application_id="ai.capture.ui") +CHAT_DIR.mkdir(parents=True, exist_ok=True) + +# Config dosyasından son açılan chat'i al, yoksa default.json kullan +if CONFIG_PATH.exists(): + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + config = json.load(f) or {} + last_chat_name = config.get("last_chat", "default.json") + dark_mode = config.get("dark_mode", True) +else: + config = {} + last_chat_name = "default.json" + dark_mode = True + +DEFAULT_CHAT = CHAT_DIR / last_chat_name +if not DEFAULT_CHAT.exists(): + DEFAULT_CHAT.write_text("[]", encoding="utf-8") + + +class ChatApp(Gtk.Application): + def __init__(self, image_path=None): + super().__init__() + self.connect("activate", self.on_activate) + + self.current_chat = DEFAULT_CHAT self.image_path = image_path - def do_activate(self): - win = Gtk.ApplicationWindow(application=self) - win.set_title("AI Capture") - win.set_default_size(900, 800) + self.pending_images = [] + self.pending_files = [] - root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - root.set_margin_top(10) - root.set_margin_bottom(10) - root.set_margin_start(10) - root.set_margin_end(10) + if image_path: + self.pending_images = [image_path] - # === IMAGE === - picture = Gtk.Picture.new_for_filename(self.image_path) - picture.set_hexpand(True) - picture.set_vexpand(True) - picture.set_content_fit(Gtk.ContentFit.CONTAIN) + self.sidebar_expanded = True + self.chats_list_open = True + self.models_list_open = True - frame = Gtk.Frame() - frame.set_hexpand(True) - frame.set_vexpand(True) - frame.set_child(picture) - root.append(frame) + self.auto_focus_enabled = True + self.auto_scroll_enabled = True + self.selected_indexes = set() + self.selection_mode = False - # === INPUT ROW === - row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + # aktif chat’in modeli (chat başına) + self.active_model = None - self.entry = Gtk.Entry() - self.entry.set_placeholder_text("Bu görüntü hakkında sorunuzu yazın…") - self.entry.set_hexpand(True) - row.append(self.entry) + # ---------------- CONFIG HELPERS ---------------- - self.ask_btn = Gtk.Button(label="Sor") - self.ask_btn.connect("clicked", self.on_ask) - row.append(self.ask_btn) + 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 {} - menu_btn = Gtk.Button(label="⋮") - row.append(menu_btn) + 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) - root.append(row) + def _get_models_list(self, cfg: dict): + models = cfg.get("ai_models", []) + if not isinstance(models, list): + models = [] + # normalize + models = [str(m).strip() for m in models if str(m).strip()] + return models - # === OUTPUT === - self.output = Gtk.TextView() - self.output.set_editable(False) - self.output.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - - out_scroll = Gtk.ScrolledWindow() - out_scroll.set_vexpand(True) - out_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - out_scroll.set_child(self.output) - - root.append(out_scroll) - - # === MODEL POPOVER === - popover = Gtk.Popover() - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) - box.set_margin_top(8) - box.set_margin_bottom(8) - box.set_margin_start(8) - box.set_margin_end(8) - - self.checks = {} - for m in MODELS: - cb = Gtk.CheckButton(label=m) - cb.set_active(m == MODELS[0]) - self.checks[m] = cb - box.append(cb) - - popover.set_child(box) - popover.set_parent(menu_btn) - menu_btn.connect("clicked", lambda *_: popover.popup()) - - win.set_child(root) - win.present() - - def on_ask(self, _): - prompt = self.entry.get_text().strip() - if not prompt: - return - - models = [m for m, cb in self.checks.items() if cb.get_active()] + def _ensure_min_one_model(self, cfg: dict): + """ + Senin isteğin: minimum 1 model kalmalı. + Eğer config boşsa burada bir default ekliyoruz. + İstersen bunu hata yapacak şekilde değiştirebilirsin. + """ + models = self._get_models_list(cfg) if not models: - self.set_output("❌ En az bir model seçmelisiniz.") + models = ["google/gemini-2.5-flash-image"] + cfg["ai_models"] = models + self.save_config(cfg) + return models + + def get_default_model(self, cfg: dict) -> str: + models = self._ensure_min_one_model(cfg) + return str(models[0]) + + def get_chat_model(self, chat_path: Path) -> str: + """ + Bu chat için model: + - cfg.chat_models[chat_name] varsa ve ai_models içinde ise onu döndür + - yoksa / silinmişse => ai_models[0] + config’e yaz (self-heal) + """ + cfg = self.load_config() + models = self._ensure_min_one_model(cfg) + + chat_models = cfg.get("chat_models", {}) + if not isinstance(chat_models, dict): + chat_models = {} + + key = chat_path.name + m = str(chat_models.get(key) or "").strip() + + if (not m) or (m not in models): + m = str(models[0]) + chat_models[key] = m + cfg["chat_models"] = chat_models + self.save_config(cfg) + + return m + + def set_chat_model(self, chat_path: Path, model_id: str): + """ + Bu chat’in modelini set eder + LRU (en son seçilen model en üste) + """ + model_id = str(model_id).strip() + if not model_id: return - self.ask_btn.set_sensitive(False) - self.set_output("⏳ Yanıt alınıyor…") + cfg = self.load_config() + models = self._ensure_min_one_model(cfg) - threading.Thread( - target=self.run_ai, - args=(prompt, models), - daemon=True - ).start() + # LRU: seçileni en üste al + if model_id not in models: + models.insert(0, model_id) + else: + models = [m for m in models if m != model_id] + models.insert(0, model_id) - def run_ai(self, prompt, models): - for model in models: - try: - result = subprocess.run( - [ - "python", - AI_SCRIPT, - self.image_path, - prompt, - model - ], - capture_output=True, - text=True, - timeout=120 - ) + cfg["ai_models"] = models - if result.returncode == 0 and result.stdout.strip(): - GLib.idle_add(self.on_done, result.stdout.strip()) - return - except Exception as e: - last_error = str(e) + chat_models = cfg.get("chat_models", {}) + if not isinstance(chat_models, dict): + chat_models = {} + chat_models[chat_path.name] = model_id + cfg["chat_models"] = chat_models - GLib.idle_add(self.on_done, f"❌ Tüm modeller başarısız.\n{last_error}") + # selected_model artık gereksiz; istersen yazma: + # cfg["selected_model"] = model_id - def on_done(self, text): - self.set_output(text) - self.ask_btn.set_sensitive(True) + self.save_config(cfg) + + # UI state + self.active_model = model_id + self.load_models_list() + self.apply_models_visibility() + + # ---------------- UI BUILD ---------------- + + def on_activate(self, app): + self.win = Gtk.ApplicationWindow(application=app) + self.win.set_title("Capture AI") + self.win.set_default_size(1100, 750) + self.apply_theme(dark_mode) + self.apply_css() + + root = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.win.set_child(root) + + # SIDEBAR + self.sidebar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self.sidebar.add_css_class("sidebar") + self.sidebar.set_size_request(250, -1) + self.sidebar.set_hexpand(False) + self.sidebar.set_halign(Gtk.Align.START) + + self.toggle_btn = Gtk.Button(label="☰") + self.toggle_btn.connect("clicked", self.toggle_sidebar) + self.sidebar.append(self.toggle_btn) + + # CHATS HEADER + self.chats_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + + self.chats_toggle_btn = Gtk.Button(label="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.new_btn.set_tooltip_text("Yeni Sohbet") + self.new_btn.connect("clicked", self.create_chat) + self.chats_header.append(self.new_btn) + + self.sidebar.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.append(self.chat_list) + + # 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="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.add_model_btn.set_tooltip_text("Model ekle") + self.add_model_btn.connect("clicked", self.open_add_model_dialog) + self.models_header.append(self.add_model_btn) + + self.sidebar.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.append(self.models_list) + + # Spacer + spacer = Gtk.Box() + spacer.set_vexpand(True) + self.sidebar.append(spacer) + + # Dark mode + dark_mode_btn = Gtk.Button(label="🌓") + dark_mode_btn.set_tooltip_text("Toggle Dark Mode") + dark_mode_btn.connect("clicked", self.toggle_theme) + self.sidebar.append(dark_mode_btn) + + # API key + key_btn = Gtk.Button() + key_btn.set_icon_name("dialog-password-symbolic") + key_btn.set_tooltip_text("OpenRouter API Key") + key_btn.connect("clicked", self.open_key_dialog) + self.sidebar.append(key_btn) + + root.append(self.sidebar) + + # MAIN + main = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + main.set_hexpand(True) + main.set_vexpand(True) + main.set_halign(Gtk.Align.FILL) + root.append(main) + + # OVERLAY + overlay = Gtk.Overlay() + overlay.set_hexpand(True) + overlay.set_vexpand(True) + main.append(overlay) + + # Scroll content + self.content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.content_box.set_hexpand(True) + self.content_box.set_vexpand(True) + + self.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) + + 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.selection_clear_btn.set_tooltip_text("Seçimi temizle") + 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_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(True) + self.preview_scroller.set_min_content_height(90) + + self.preview_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self.preview_hbox.set_halign(Gtk.Align.START) + self.preview_scroller.set_child(self.preview_hbox) + + self.preview_row.append(self.preview_scroller) + + self.preview_next_btn = Gtk.Button(label=">") + self.preview_next_btn.add_css_class("flat") + self.preview_next_btn.set_tooltip_text("Sağa kaydır") + self.preview_next_btn.connect("clicked", self.scroll_preview_right) + self.preview_next_btn.set_visible(False) # sadece taşınca + self.preview_row.append(self.preview_next_btn) + + # başta gizli (chat zıplamasın istersen opacity yaklaşımı da kullanabilirsin) + self.preview_row.set_visible(False) + self.content_box.append(self.preview_row) + + self.refresh_attachments_preview() + + self.bottom_spacer = Gtk.Box() + self.bottom_spacer.set_size_request(-1, 110) + self.content_box.append(self.bottom_spacer) + + self.scroll = Gtk.ScrolledWindow() + self.scroll.set_hexpand(True) + self.scroll.set_vexpand(True) + self.scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.scroll.set_child(self.content_box) + overlay.set_child(self.scroll) + vadj = self.scroll.get_vadjustment() + vadj.connect("value-changed", self.on_scroll_changed) + + # Floating input + 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_margin_bottom(12) + overlay.add_overlay(input_wrapper) + + # 🔽 Scroll to bottom button + self.scroll_btn = Gtk.Button(label="↓") + self.scroll_btn.set_size_request(36, 36) + self.scroll_btn.set_valign(Gtk.Align.END) + self.scroll_btn.set_halign(Gtk.Align.END) + self.scroll_btn.set_margin_bottom(80) # input'un üstünde dursun + self.scroll_btn.set_margin_end(20) + self.scroll_btn.add_css_class("scroll-btn") + self.scroll_btn.connect("clicked", self.on_scroll_button_clicked) + self.scroll_btn.set_visible(False) + overlay.add_overlay(self.scroll_btn) + + clamp = Adw.Clamp() + clamp.set_hexpand(True) + clamp.set_halign(Gtk.Align.FILL) + clamp.props.maximum_size = 800 + clamp.props.tightening_threshold = 800 + input_wrapper.append(clamp) + + input_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + input_row.set_hexpand(True) + input_row.set_halign(Gtk.Align.FILL) + clamp.set_child(input_row) + + attach_btn = Gtk.Button(label="📎") + attach_btn.set_valign(Gtk.Align.END) + attach_btn.set_tooltip_text("Dosya ekle (fotoğraf / belge)") + attach_btn.add_css_class("send_btn") + attach_btn.connect("clicked", self.on_attach_clicked) + input_row.append(attach_btn) + + 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.add_css_class("chat-textview") + + 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(32) + self.text_scroll.set_max_content_height(140) + self.text_scroll.add_css_class("chat-input-box") + self.text_scroll.set_child(self.textview) + input_row.append(self.text_scroll) + + mic_btn = Gtk.Button(label="🎙️") + mic_btn.set_valign(Gtk.Align.END) + mic_btn.set_tooltip_text("Konuşarak yaz (başlat/durdur)") + mic_btn.connect("clicked", self.toggle_voice_input) + mic_btn.add_css_class("send_btn") + input_row.append(mic_btn) + + self.mic_btn = mic_btn + self._rec_stop = threading.Event() + self._rec_thread = None + self._voice_proc = None + self._last_wav = None + + send_btn = Gtk.Button(label="↑") + send_btn.set_valign(Gtk.Align.END) + send_btn.connect("clicked", lambda *_: self.send_message(None)) + send_btn.add_css_class("send_btn") + input_row.append(send_btn) + + # key controller + key_controller = Gtk.EventControllerKey() + key_controller.connect("key-pressed", self.on_textview_key_pressed) + self.textview.add_controller(key_controller) + + # aktif chat modeli yükle + self.active_model = self.get_chat_model(self.current_chat) + + # load initial + self.load_chat_list() + self.load_chat() + self.apply_chats_visibility() + self.load_models_list() + self.apply_models_visibility() + + self.win.present() + GLib.idle_add(self.force_focus_entry) + + # ---------------- CSS / THEME ---------------- + + def apply_css(self): + css = """ + .chat-input-box { + border-radius: 14px; + margin-bottom: 8px; + padding: 6px 10px; + } + + .attach-chip { + padding: 6px 10px; + border-radius: 10px; + background: rgba(255,255,255,0.06); + } + window.light .attach-chip { + background: rgba(0,0,0,0.06); + } + + window.dark .chat-input-box { + background: #1e1e1e; + } + + window.light .chat-input-box { + background: gray; + } + + .send_btn { + margin-bottom: 14px; + } + + .chat-textview { + background: transparent; + } + + .selected-chat { + background: #d0e0ff; + border-radius: 6px; + } + + .sidebar { + padding: 8px; + } + + .user-bubble { + background: #303643; + border-radius: 12px; + padding: 10px; + margin-right: 10px; + color: white; + } + + .bot-bubble { + border-radius: 12px; + padding: 10px; + margin-left: 10px; + } + + .user-bubble-ref { + background: #237227; /* koyu yeşil gibi (dark mode) */ + border: 1px solid rgba(255,255,255,0.08); + } + + window.light .user-bubble-ref { + background: #84B179; /* koyu yeşil gibi (dark mode) */ + color: #111; + border: 1px solid rgba(0,0,0,0.08); + } + + .user-bubble-regen { + background: #5a3a8a; /* koyu mor (dark mode) */ + border: 1px solid rgba(255,255,255,0.08); + } + + window.light .user-bubble-regen { + background: #e9dcff; /* açık mor (light mode) */ + color: #111; + border: 1px solid rgba(0,0,0,0.08); + } + + .refs-preview { + padding: 6px 8px; + border-radius: 10px; + margin-bottom: 6px; + background: rgba(255,255,255,0.06); + } + + .refs-preview-line { + opacity: 0.55; + font-size: 0.90em; + } + + window.light .refs-preview { + background: rgba(0,0,0,0.06); + } + + window.light .refs-preview-line { + opacity: 0.65; + } + + .refs-groups { + margin-bottom: 6px; + } + + .hovered { + box-shadow: 0 0 6px rgba(0,0,0,0.25); + } + + .selected { + outline: 2px solid #ff9800; + } + + + .code-block { + background: #1e1e1e; + border-radius: 8px; + padding: 12px; + font-family: monospace; + } + + window.light .code-block { + background: #F9F9F9; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); + } + + .code-copy-btn { + color: white; + } + + window.light .code-copy-btn{ + color: #1e1e1e; + } + + .attach-edit-on { + outline: 2px solid #2ecc71; + border-radius: 10px; + } + """ + provider = Gtk.CssProvider() + provider.load_from_data(css.encode()) + display = Gdk.Display.get_default() + if display: + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + def apply_theme(self, is_dark: bool): + is_dark = bool(is_dark) + style_manager = Adw.StyleManager.get_default() + style_manager.set_color_scheme( + Adw.ColorScheme.FORCE_DARK if is_dark else Adw.ColorScheme.FORCE_LIGHT + ) + self.win.remove_css_class("dark") + self.win.remove_css_class("light") + self.win.add_css_class("dark" if is_dark else "light") + + def toggle_theme(self, *_): + cfg = self.load_config() + current = cfg.get("dark_mode", True) + new_value = not current + cfg["dark_mode"] = new_value + self.save_config(cfg) + self.apply_theme(new_value) + + # ---------------- INPUT ---------------- + + def force_focus_entry(self): + self.textview.grab_focus() return False - def set_output(self, text): - buf = self.output.get_buffer() - buf.set_text(text) + 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: + # Shift+Enter = newline + if shift: + return False + # Enter = send + GLib.idle_add(self.send_message, None) + return True + + return False + + # ---------------- IMAGE PREVIEW ---------------- + + 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 on_attach_clicked(self, *_): + # GTK4 FileDialog ile çoklu seçim + dialog = Gtk.FileDialog() + + # Filter’lar + 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("*") + + flist = Gio.ListStore.new(Gtk.FileFilter) + flist.append(img_filter) + flist.append(any_filter) + dialog.set_filters(flist) + + def on_done(dlg, res): + try: + files = dlg.open_multiple_finish(res) # Gio.ListModel + except Exception: + return + + if not files: + return + + import mimetypes + for i in range(files.get_n_items()): + gfile = files.get_item(i) + if not gfile: + continue + from urllib.parse import urlparse, unquote + + path = gfile.get_path() + + # Bazı sistemlerde get_path() None döner; URI'den düşür + if not path: + uri = gfile.get_uri() or "" + if uri.startswith("file://"): + path = unquote(urlparse(uri).path) + + # Hâlâ yoksa (remote vb.) geç + 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: + # belge vb. -> dict olarak ekle + duplicate engelle + 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() + + dialog.open_multiple(self.win, None, on_done) + + def refresh_attachments_preview(self): + # 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 self.pending_images: + img_path = Path(p) + if not img_path.exists(): + continue + + item = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + item.set_halign(Gtk.Align.START) + + file = Gio.File.new_for_path(str(img_path.resolve())) + pic = Gtk.Picture.new_for_file(file) + pic.set_content_fit(Gtk.ContentFit.COVER) + pic.set_size_request(96, 72) + + name = Gtk.Label(label=img_path.name) + name.set_xalign(0) + name.set_max_width_chars(18) + name.set_ellipsize(Pango.EllipsizeMode.END) + + item.append(pic) + item.append(name) + self.preview_hbox.append(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") + + 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) + lab.set_max_width_chars(26) + lab.set_ellipsize(Pango.EllipsizeMode.END) + chip.append(lab) + + # 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) + + self.preview_hbox.append(chip) + + # taşma var mı? (butonu ona göre aç/kapat) + GLib.idle_add(self._update_preview_overflow) + + def _update_preview_overflow(self): + try: + hadj = self.preview_scroller.get_hadjustment() + if not hadj: + return False + overflow = hadj.get_upper() > hadj.get_page_size() + 2 + self.preview_next_btn.set_visible(bool(overflow)) + 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 + + # ---------------- AI ERROR / TYPING ---------------- + + def handle_ai_error(self, error_text): + if hasattr(self, "typing_row"): + self.chat_box.remove(self.typing_row) + del self.typing_row + del self.typing_label + + try: + with open(self.current_chat, "r", encoding="utf-8") as f: + messages = json.load(f) or [] + except Exception: + messages = [] + + messages.append({"role": "bot", "content": f"❌ Hata:\n{error_text}"}) + + try: + with open(self.current_chat, "w", encoding="utf-8") as f: + json.dump(messages, f, ensure_ascii=False, indent=2) + except Exception: + pass + + self.load_chat() + self.scroll_to_bottom() + + def show_typing_indicator(self): + self.typing_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.typing_row.set_halign(Gtk.Align.START) + + bubble = Gtk.Box() + bubble.add_css_class("bot-bubble") + + self.typing_label = Gtk.Label(label="Düşünüyor") + bubble.append(self.typing_label) + + self.typing_row.append(bubble) + self.chat_box.append(self.typing_row) + + self.typing_dots = 0 + GLib.timeout_add(500, self.animate_typing) + + self.scroll_to_bottom() + + def animate_typing(self): + if not hasattr(self, "typing_label"): + return False + dots = "." * (self.typing_dots % 4) + self.typing_label.set_text(f"Düşünüyor{dots}") + self.typing_dots += 1 + return True + + def after_ai_response(self): + if hasattr(self, "typing_row"): + self.chat_box.remove(self.typing_row) + del self.typing_row + del self.typing_label + + self.load_chat() + self.scroll_to_bottom() + + # ---------------- MODELS LIST ---------------- + + def apply_models_visibility(self): + if not getattr(self, "sidebar_expanded", True): + self.models_header.set_visible(False) + self.models_list.set_visible(False) + return + + self.models_header.set_visible(True) + self.models_list.set_visible(self.models_list_open) + self.models_toggle_btn.set_label("AI models ▾" if self.models_list_open else "AI models ▸") + + def on_model_row_activated(self, listbox, row): + if not row: + return + + model_id = getattr(row, "model_id", None) + if not model_id: + return + + self.set_chat_model(self.current_chat, model_id) + + def toggle_models_list(self, *_): + self.models_list_open = not self.models_list_open + self.apply_models_visibility() + + def confirm_delete_model(self, button, model_id: str): + cfg = self.load_config() + models = self._ensure_min_one_model(cfg) + + # 1 model kaldıysa sildirme + if len(models) <= 1: + dialog = Gtk.Dialog() + dialog.set_transient_for(self.win) + dialog.set_modal(True) + dialog.set_title("Model Silinemez") + content = dialog.get_content_area() + content.append(Gtk.Label(label="En az 1 model kalmalı. Son modeli silemezsin.")) + dialog.add_button("Tamam", Gtk.ResponseType.OK) + dialog.connect("response", lambda d, r: d.destroy()) + dialog.present() + return + + dialog = Gtk.Dialog() + dialog.set_transient_for(self.win) + dialog.set_modal(True) + dialog.set_title("Model Sil") + + content = dialog.get_content_area() + label = Gtk.Label(label=f"Bu modeli silmek istiyor musun?\n\n{model_id}") + label.set_wrap(True) + content.append(label) + + dialog.add_button("İptal", Gtk.ResponseType.CANCEL) + dialog.add_button("Sil", Gtk.ResponseType.OK) + + def on_response(d, resp): + if resp == Gtk.ResponseType.OK: + cfg2 = self.load_config() + models2 = self._ensure_min_one_model(cfg2) + + if len(models2) <= 1: + d.destroy() + return + + models2 = [m for m in models2 if m != model_id] + cfg2["ai_models"] = models2 + + # silinen model chatlerde kullanılıyorsa default’a çek + chat_models = cfg2.get("chat_models", {}) + if not isinstance(chat_models, dict): + chat_models = {} + + new_default = str(models2[0]) if models2 else None + for k, v in list(chat_models.items()): + if v == model_id: + chat_models[k] = new_default + cfg2["chat_models"] = chat_models + + self.save_config(cfg2) + + # aktif chat modeli self-heal + self.active_model = self.get_chat_model(self.current_chat) + + self.load_models_list() + self.apply_models_visibility() + + d.destroy() + + dialog.connect("response", on_response) + dialog.present() + + def open_add_model_dialog(self, *_): + dialog = Gtk.Dialog() + dialog.set_transient_for(self.win) + dialog.set_modal(True) + dialog.set_title("Model Ekle") + + content = dialog.get_content_area() + + info = Gtk.Label(label="Model ID gir (ör: openai/gpt-4.1-mini)") + info.set_wrap(True) + info.set_xalign(0) + content.append(info) + + entry = Gtk.Entry() + entry.set_placeholder_text("provider/model (ör: google/gemini-2.5-flash-image)") + content.append(entry) + + # Ctrl+V ile yapıştırmayı garanti et + key_controller = Gtk.EventControllerKey() + + def on_key_pressed(controller, keyval, keycode, state): + ctrl = bool(state & Gdk.ModifierType.CONTROL_MASK) + + # hem v hem V yakala + if ctrl and keyval in (Gdk.KEY_v, Gdk.KEY_V): + display = Gdk.Display.get_default() + if not display: + return False + + clipboard = display.get_clipboard() + + def on_text(cb, res): + try: + text = cb.read_text_finish(res) or "" + except Exception: + text = "" + if text: + entry.set_text(text.strip()) + + clipboard.read_text_async(None, on_text) + return True + + return False + + key_controller.connect("key-pressed", on_key_pressed) + entry.add_controller(key_controller) + + # Link + link = Gtk.LinkButton.new_with_label( + "https://openrouter.ai/models", + "OpenRouter – Models sayfasını aç" + ) + link.set_halign(Gtk.Align.START) + content.append(link) + + dialog.add_button("İptal", Gtk.ResponseType.CANCEL) + dialog.add_button("Ekle", Gtk.ResponseType.OK) + + def on_response(d, resp): + if resp == Gtk.ResponseType.OK: + model_id = entry.get_text().strip() + if model_id: + # mevcut set_chat_model LRU mantığıyla listeye ekliyor ve bu chat’e atıyor + self.set_chat_model(self.current_chat, model_id) + d.destroy() + + dialog.connect("response", on_response) + dialog.present() + + def _focus(): + entry.grab_focus() + return False + GLib.idle_add(_focus) + + def load_models_list(self): + while True: + row = self.models_list.get_first_child() + if not row: + break + self.models_list.remove(row) + + cfg = self.load_config() + models = self._ensure_min_one_model(cfg) + active = self.get_chat_model(self.current_chat) + self.active_model = active + + for model_id in models: + model_id = str(model_id) + + row = Gtk.ListBoxRow() + row.model_id = model_id # <-- önemli + + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + box.set_hexpand(True) + + prefix = "✓ " if active == model_id else "" + label = Gtk.Label(label=prefix + model_id) + label.set_xalign(0) + label.set_hexpand(True) + label.set_halign(Gtk.Align.FILL) + label.set_single_line_mode(True) + label.set_ellipsize(Pango.EllipsizeMode.END) + label.set_max_width_chars(22) + box.append(label) + + trash_btn = Gtk.Button(label="🗑") + trash_btn.set_tooltip_text("Modeli sil") + 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(label="Yeni isim:") + title.set_halign(Gtk.Align.START) + vbox.append(title) + + entry = Gtk.Entry() + entry.set_placeholder_text("ör: my_chat") + entry.set_text(file_path.stem) + + 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(label="(Dosya adı olarak kaydedilir)") + 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="İptal") + save_btn = Gtk.Button(label="Kaydet") + save_btn.add_css_class("suggested-action") + + cancel_btn.connect("clicked", lambda *_: rename_pop.popdown()) + save_btn.connect("clicked", do_rename) + + btn_row.append(cancel_btn) + btn_row.append(save_btn) + vbox.append(btn_row) + + rename_pop.set_child(vbox) + rename_pop.popup() + + # focus/select + def _focus(): + entry.select_region(0, -1) + entry.grab_focus() + return False + GLib.idle_add(_focus) + + def rename_chat_file(self, old_path: Path, new_name_raw: str, pop: Gtk.Popover): + new_name = (new_name_raw or "").strip() + if not new_name: + return + + for ch in ["/", "\\", "\n", "\r", "\t"]: + new_name = new_name.replace(ch, "_") + + new_path = old_path.with_name(new_name + ".json") + + if new_path == old_path: + pop.popdown() + return + + if new_path.exists(): + print(f"Rename failed: {new_path.name} zaten var.") + return + + try: + old_path.rename(new_path) + except Exception as e: + print("Rename error:", e) + return + + # pinned update + last_chat update + chat_models update + cfg = self.load_config() + + plist = cfg.get("pinned_chats", []) + if isinstance(plist, list) and old_path.name in plist: + cfg["pinned_chats"] = [new_path.name if x == old_path.name else x for x in plist] + + if cfg.get("last_chat") == old_path.name: + cfg["last_chat"] = new_path.name + + chat_models = cfg.get("chat_models", {}) + if isinstance(chat_models, dict) and old_path.name in chat_models: + chat_models[new_path.name] = chat_models.pop(old_path.name) + cfg["chat_models"] = chat_models + + self.save_config(cfg) + + if self.current_chat == old_path: + self.current_chat = new_path + self.active_model = self.get_chat_model(self.current_chat) + + pop.popdown() + self.load_chat_list() + self.load_models_list() + self.load_chat() + + # ---------------- PIN TOGGLE (ESKİ ÖZELLİK) ---------------- + + def toggle_pin_chat(self, file_path: Path): + cfg = self.load_config() + pinned = cfg.get("pinned_chats", []) + if not isinstance(pinned, list): + pinned = [] + + name = file_path.name + if name in pinned: + pinned.remove(name) + else: + pinned.append(name) + + cfg["pinned_chats"] = pinned + self.save_config(cfg) + self.load_chat_list() + + # ---------------- API KEY DIALOG ---------------- + + def open_key_dialog(self, button): + dialog = Gtk.Dialog() + dialog.set_transient_for(self.win) + dialog.set_modal(True) + dialog.set_title("OpenRouter API Key") + + content = dialog.get_content_area() + + entry = Gtk.Entry() + entry.set_placeholder_text("OpenRouter API Key girin...") + entry.set_visibility(False) + content.append(entry) + + dialog.add_button("İptal", Gtk.ResponseType.CANCEL) + dialog.add_button("Kaydet", Gtk.ResponseType.OK) + + def on_response(dialog, response): + if response == Gtk.ResponseType.OK: + new_key = entry.get_text().strip() + if new_key: + cfg = self.load_config() + cfg["open_router_key"] = new_key + self.save_config(cfg) + dialog.destroy() + + dialog.connect("response", on_response) + dialog.present() + + # ---------------- CHAT LIST ---------------- + + def load_chat_list(self): + while True: + row = self.chat_list.get_first_child() + if not row: + break + self.chat_list.remove(row) + + cfg = self.load_config() + pinned = cfg.get("pinned_chats", []) + if not isinstance(pinned, list): + pinned = [] + + files = list(CHAT_DIR.glob("*.json")) + + def mtime(p): + try: + return p.stat().st_mtime + except Exception: + return 0.0 + + def sort_key(p): + return (0 if p.name in pinned else 1, -mtime(p), p.name.lower()) + + for file in sorted(files, key=sort_key): + row = Gtk.ListBoxRow() + row.chat_path = file + + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + box.set_hexpand(True) + + if file.name in pinned: + pin_label = Gtk.Label(label="📌") + pin_label.set_xalign(0) + box.append(pin_label) + + label = Gtk.Label(label=file.stem) + label.set_xalign(0) + label.set_hexpand(True) + label.set_halign(Gtk.Align.FILL) + box.append(label) + + more_btn = Gtk.Button(label="⋯") + more_btn.set_tooltip_text("Seçenekler") + 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=("Sabiti kaldır" if is_pinned else "Sabitle")) + 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="İsmi değiştir") + 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="Chat'i sil") + 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) + dialog.set_modal(True) + dialog.set_title("Chat Sil") + + content = dialog.get_content_area() + label = Gtk.Label(label="Bu chat silinsin mi?\nBu işlem geri alınamaz.") + label.set_wrap(True) + content.append(label) + + dialog.add_button("İptal", Gtk.ResponseType.CANCEL) + dialog.add_button("Sil", Gtk.ResponseType.OK) + + def on_response(dialog, response): + if response == Gtk.ResponseType.OK: + if file_path.exists(): + file_path.unlink() + + cfg = self.load_config() + + pinned = cfg.get("pinned_chats", []) + if isinstance(pinned, list) and file_path.name in pinned: + cfg["pinned_chats"] = [x for x in pinned if x != file_path.name] + + # chat_models cleanup + chat_models = cfg.get("chat_models", {}) + if isinstance(chat_models, dict) and file_path.name in chat_models: + del chat_models[file_path.name] + cfg["chat_models"] = chat_models + + if cfg.get("last_chat") == file_path.name: + cfg["last_chat"] = "default.json" + + self.save_config(cfg) + + if self.current_chat == file_path: + chats = list(CHAT_DIR.glob("*.json")) + if chats: + self.current_chat = chats[0] + else: + DEFAULT_CHAT.write_text("[]", encoding="utf-8") + self.current_chat = DEFAULT_CHAT + + # active model for new current chat + self.active_model = self.get_chat_model(self.current_chat) + + self.load_chat_list() + self.load_chat() + self.load_models_list() + + dialog.destroy() + + dialog.connect("response", on_response) + dialog.present() + + # ---------------- CHAT VISIBILITY / SIDEBAR ---------------- + + def apply_chats_visibility(self): + if not getattr(self, "sidebar_expanded", True): + self.chat_list.set_visible(False) + self.chats_toggle_btn.set_label("Chats ▾" if self.chats_list_open else "Chats ▸") + return + self.chat_list.set_visible(self.chats_list_open) + self.chats_toggle_btn.set_label("Chats ▾" if self.chats_list_open else "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.chats_toggle_btn.set_visible(False) + 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.chats_toggle_btn.set_visible(True) + self.apply_chats_visibility() + self.apply_models_visibility() + + # ----------------COPY / REGENERATE -------------------- + + def find_prev_user_index(self, messages: list, from_idx: int): + j = from_idx - 1 + while j >= 0: + if messages[j].get("role") == "user": + return j + j -= 1 + return None + + def build_regen_prompt_for_bot(self, question: str, answer: str) -> str: + return ( + "Aşağıdaki soru ve verilen cevaba benzer bir sonuç üret.\n" + "Aynı yaklaşımı ve içeriği koru, ama metni birebir kopyalama; yeniden yaz " + "ve mümkünse küçük iyileştirmeler yap.\n\n" + "[SORU]\n" + f"{question.strip()}\n\n" + "[ÖNCEKİ CEVAP]\n" + f"{answer.strip()}\n" + ) + + def copy_message_by_index(self, idx: int): + try: + with open(self.current_chat, "r", encoding="utf-8") as f: + messages = json.load(f) or [] + except Exception: + return + + if not (0 <= idx < len(messages)): + return + + content = (messages[idx].get("content") or "") + if not content: + return + + inner = self.extract_copy_block(content) + self.copy_to_clipboard(inner if inner is not None else content) + + def regenerate_by_index(self, idx: int): + # 1) Chat'i oku + try: + with open(self.current_chat, "r", encoding="utf-8") as f: + messages = json.load(f) or [] + except Exception: + return + + if not (0 <= idx < len(messages)): + return + + role = (messages[idx].get("role") or "").strip() + content = (messages[idx].get("content") or "").strip() + if not content: + return + + # 2) regen_text üret + regen_text = None + q_idx = None + + if role == "user": + # user seçiliyse: aynısını tekrar sor + regen_text = content + else: + # bot seçiliyse: bağlı user sorusunu bul + bot cevabı ile benzer üret + q_idx = self.find_prev_user_index(messages, idx) + question = (messages[q_idx].get("content") or "").strip() if q_idx is not None else "" + + # bot cevabı copy bloğuysa içeriğini al (daha iyi prompt) + ans_inner = self.extract_copy_block(content) + answer = ans_inner if ans_inner is not None else content + + if question: + # build_regen_prompt_for_bot senin mevcut fonksiyonun (eski özellik) + regen_text = self.build_regen_prompt_for_bot(question, answer) + else: + # user bulunamadıysa sadece cevaba benzer üret + regen_text = ( + "Aşağıdaki cevaba benzer bir sonuç üret. Metni birebir kopyalama; yeniden yaz ve iyileştir.\n\n" + f"[ÖNCEKİ CEVAP]\n{answer}\n" + ) + + if not regen_text: + return + + # 3) Referans zinciri: seçilen mesajın used_refs'lerini de dahil et + # (böylece içinde referans olan mesaj regenerate edilirken foto/ref kaybolmaz) + ref_set = set() + ref_set.add(idx) + + used = messages[idx].get("used_refs") or [] + if isinstance(used, list): + for j in used: + if isinstance(j, int) and 0 <= j < len(messages): + ref_set |= self.expand_ref_chain(messages, j) + + # bot seçildiyse bağlı user'ı da dahil et + onun zinciri + if role != "user" and q_idx is not None: + ref_set.add(q_idx) + used_q = messages[q_idx].get("used_refs") or [] + if isinstance(used_q, list): + for j in used_q: + if isinstance(j, int) and 0 <= j < len(messages): + ref_set |= self.expand_ref_chain(messages, j) + + selected_arg = ",".join(str(x) for x in sorted(ref_set)) + + # 4) Chat sonuna yeni user mesajı ekle (regen flag + referans meta + preview) + new_user = { + "role": "user", + "content": regen_text, + "regen": True, # regenerate rengi/işareti + "used_refs": sorted(ref_set), # AI'ya giden referans setiyle uyumlu meta + } + + # UI'da karışmaması için: seçilen balon + (bot ise bağlı user) ayrı gruplar + groups = [] + + def _collect_group_for_index(ridx: int): + if not (0 <= ridx < len(messages)): + return None + rmsg = messages[ridx] + items = [] + + # referans mesajının kendi önceki preview'ları (yeni format) + rg = rmsg.get("refs_groups") + if isinstance(rg, list) and rg: + for g in rg: + its = g.get("items") if isinstance(g, dict) else None + if isinstance(its, list): + for it in its: + if isinstance(it, dict) and it.get("text"): + items.append({"role": it.get("role", ""), "text": it.get("text", "")}) + else: + # eski format kalmış olabilir + rp = rmsg.get("refs_preview") + if isinstance(rp, list) and rp: + for it in rp: + if isinstance(it, dict) and it.get("text"): + items.append({"role": it.get("role", ""), "text": it.get("text", "")}) + + # en sona referans mesajın kendisini koy (metin + image varsa pack_preview_item ekler) + try: + items.append(self.pack_preview_item(rmsg)) + except Exception: + # pack_preview_item yoksa yine de minimum göster + rrole = (rmsg.get("role") or "").strip() + rtext = (rmsg.get("content") or "").strip() + if not rtext and rmsg.get("image"): + rtext = "[image]" + items.append({"role": rrole, "text": rtext}) + + return {"items": items} + + g1 = _collect_group_for_index(idx) + if g1 is not None: + groups.append(g1) + + if role != "user" and q_idx is not None: + g2 = _collect_group_for_index(q_idx) + if g2 is not None: + groups.append(g2) + + new_user["refs_groups"] = groups + + messages.append(new_user) + + try: + with open(self.current_chat, "w", encoding="utf-8") as f: + json.dump(messages, f, ensure_ascii=False, indent=2) + except Exception: + return + + # 5) UI: seçim temizle + typing indicator + self.selected_indexes.clear() + self.selection_mode = False + self.update_selection_label() + self.auto_scroll_enabled = True + self.load_chat() + self.show_typing_indicator() + + # 6) AI çağır: zincir referanslarla birlikte + threading.Thread(target=self.call_ai, args=(selected_arg,), daemon=True).start() + + def expand_ref_chain(self, messages: list, idx: int, seen=None) -> set[int]: + if seen is None: + seen = set() + if idx in seen: + return seen + seen.add(idx) + + msg = messages[idx] if 0 <= idx < len(messages) else None + if not isinstance(msg, dict): + return seen + + used = msg.get("used_refs") or [] + if isinstance(used, list): + for j in used: + if isinstance(j, int) and 0 <= j < len(messages): + self.expand_ref_chain(messages, j, seen) + return seen + + def pack_preview_item(self, rmsg: dict) -> dict: + rrole = (rmsg.get("role") or "").strip() + rtext = (rmsg.get("content") or "").strip() + has_img = bool(rmsg.get("image")) + + if not rtext and has_img: + rtext = "[image]" + elif has_img: + # metin + image birlikteyse anlaşılır olsun + rtext = rtext + " + [image]" + + return {"role": rrole, "text": rtext} + + # ---------------- CHAT CREATE / SWITCH ---------------- + + def create_chat(self, button): + i = 1 + while True: + name = f"chat_{i}.json" + path = CHAT_DIR / name + if not path.exists(): + break + i += 1 + + path.write_text("[]", encoding="utf-8") + self.current_chat = path + + cfg = self.load_config() + cfg["last_chat"] = path.name + + # yeni chat default model alır + models = self._ensure_min_one_model(cfg) + chat_models = cfg.get("chat_models", {}) + if not isinstance(chat_models, dict): + chat_models = {} + chat_models[path.name] = str(models[0]) + cfg["chat_models"] = chat_models + self.save_config(cfg) + + self.active_model = self.get_chat_model(self.current_chat) + + self.load_chat_list() + self.load_chat() + self.load_models_list() + + if not self.sidebar_expanded: + self.toggle_sidebar(None) + + self.auto_focus_enabled = True + GLib.idle_add(self.force_focus_entry) + + def switch_chat(self, listbox, row): + if not row: + return + chat_path = getattr(row, "chat_path", None) + if not chat_path: + return + + self.current_chat = Path(chat_path) + + self.selected_indexes.clear() + self.selection_mode = False + self.update_selection_label() + + cfg = self.load_config() + cfg["last_chat"] = self.current_chat.name + self.save_config(cfg) + + self.selected_indexes.clear() + self.selection_mode = False + self.selection_label.set_text("") + + self.active_model = self.get_chat_model(self.current_chat) + + self.load_chat() + self.load_models_list() + + self.auto_focus_enabled = True + GLib.idle_add(self.force_focus_entry) + + # ---------------- CHAT LOAD / SELECTION ---------------- + + def clear_selection(self, *_): + # tüm seçimleri kaldır + self.selected_indexes.clear() + + # seçim modunu kapat + self.selection_mode = False + + # label güncelle + checkboxları kaldırmak için chat’i refresh et + self.update_selection_label() + self.load_chat() + + def load_chat(self): + while True: + child = self.chat_box.get_first_child() + if not child: + break + self.chat_box.remove(child) + + with open(self.current_chat, "r", encoding="utf-8") as f: + messages = json.load(f) or [] + + for i, msg in enumerate(messages): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + row.set_hexpand(False) + + bubble = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + bubble.set_hexpand(False) + + # Hover shadow (eski özellik) + motion = Gtk.EventControllerMotion() + motion.connect("enter", lambda c, b=bubble: b.add_css_class("hovered")) + motion.connect("leave", lambda c, b=bubble: b.remove_css_class("hovered")) + bubble.add_controller(motion) + + # Click selection (eski özellik) + click = Gtk.GestureClick() + click.connect("pressed", lambda g, n, x, y, idx=i: self.on_message_clicked(idx)) + bubble.add_controller(click) + + # Referans preview (mesaj referansla gönderildiyse) + groups = msg.get("refs_groups") or [] + if isinstance(groups, list) and groups: + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + outer.add_css_class("refs-groups") + + for g in groups: + items = g.get("items") if isinstance(g, dict) else None + if not isinstance(items, list) or not items: + continue + + mini = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + mini.add_css_class("refs-preview") # aynı background mantığı + + for it in items: + if not isinstance(it, dict): + continue + rrole = (it.get("role") or "").strip() + rtext = (it.get("text") or "").strip() + if not rtext: + continue + + if len(rtext) > 200: + rtext = rtext[:200] + "..." + + line = f"{rrole}: {rtext}" if rrole else rtext + lab = Gtk.Label(label=line) + lab.set_wrap(True) + lab.set_wrap_mode(Pango.WrapMode.WORD_CHAR) + lab.set_xalign(0) + lab.add_css_class("refs-preview-line") + mini.append(lab) + + outer.append(mini) + + bubble.append(outer) + + if msg.get("content"): + content = msg["content"] + title_block = self.extract_title_block(content) + copy_block = self.extract_copy_block(content) + apply_block = self.extract_apply_block(content) + apply_ops = None + apply_err = "" + + if apply_block is not None: + apply_ops, apply_err = self.parse_apply_ops(apply_block) + + apply_summary = "" + if apply_ops: + apply_summary = self.build_apply_summary(apply_ops) + + if copy_block is not None: + # 🔹 Tek renk kod bloğu container (Title + Kopyala + Kod aynı alan) + code_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + code_container.add_css_class("code-block") + + # 🔹 Üst satır: Title solda, Kopyala sağda + top_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + top_row.set_hexpand(True) + + if title_block: + title_label = Gtk.Label() + safe = GLib.markup_escape_text(title_block.strip()) + title_label.set_markup(f"{safe}") + title_label.set_xalign(0) + title_label.set_halign(Gtk.Align.START) + top_row.append(title_label) + + spacer = Gtk.Box() + spacer.set_hexpand(True) + top_row.append(spacer) + + copy_btn = Gtk.Button(label="Kopyala") + copy_btn.add_css_class("flat") + copy_btn.add_css_class("code-copy-btn") + copy_btn.set_halign(Gtk.Align.END) + copy_btn.set_focusable(False) + + self._consume_click(copy_btn) + copy_btn.connect("clicked", lambda b, t=copy_block: self.copy_to_clipboard(t)) + top_row.append(copy_btn) + + # 🔹 Kod label + code_label = Gtk.Label(label=copy_block) + code_label.set_wrap(True) + code_label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) + code_label.set_xalign(0) + code_label.set_width_chars(0) + code_label.set_max_width_chars(60) + + code_container.append(top_row) + code_container.append(code_label) + + bubble.append(code_container) + else: + # 🔹 Normal mesaj + show_text = content + + # Eğer apply ops varsa, bot mesajında content yerine özet satırı göster + if msg.get("role") != "user" and apply_ops and apply_summary: + show_text = apply_summary + + label = Gtk.Label(label=show_text) + label.set_wrap(True) + label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) + label.set_xalign(0) + label.set_width_chars(0) + label.set_max_width_chars(60) + label.set_hexpand(False) + + bubble.append(label) + + # Tekli image (legacy) + if msg.get("image"): + img_path = Path(msg["image"]) + if img_path.exists(): + file = Gio.File.new_for_path(str(img_path)) + pic = Gtk.Picture.new_for_file(file) + pic.set_content_fit(Gtk.ContentFit.CONTAIN) + pic.set_size_request(120, 80) + bubble.append(pic) + + # Ç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 + file = Gio.File.new_for_path(str(p)) + pic = Gtk.Picture.new_for_file(file) + pic.set_content_fit(Gtk.ContentFit.COVER) + pic.set_size_request(120, 80) + row_imgs.append(pic) + + bubble.append(row_imgs) + + # Files (yeni) + files = msg.get("files") + if isinstance(files, list) and files: + files_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + for f in files: + if not isinstance(f, dict): + continue + name = (f.get("name") or Path(str(f.get("path") or "")).name or "").strip() + if not name: + continue + + chip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + chip.add_css_class("refs-preview") + + icon = Gtk.Image.new_from_icon_name("text-x-generic-symbolic") + chip.append(icon) + + lab = Gtk.Label(label=name) + lab.set_xalign(0) + lab.set_max_width_chars(40) + lab.set_ellipsize(Pango.EllipsizeMode.END) + chip.append(lab) + + files_box.append(chip) + + bubble.append(files_box) + + if msg.get("role") == "user": + bubble.add_css_class("user-bubble") + + if msg.get("regen"): + bubble.add_css_class("user-bubble-regen") + elif msg.get("used_refs"): + bubble.add_css_class("user-bubble-ref") + + row.set_halign(Gtk.Align.END) + bubble.set_halign(Gtk.Align.END) + else: + bubble.add_css_class("bot-bubble") + row.set_halign(Gtk.Align.START) + + if i in self.selected_indexes: + bubble.add_css_class("selected") + + # ---------------- APPLY BUTTONS ---------------- + 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 = "✅ Dosya değiştirildi." + elif status == "fail": + txt = f"❌ APPLY FAILED: {err}" + elif status == "cancel": + txt = "⏭️ İşlem iptal edildi." + 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) + + 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: + btn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + btn_row.set_halign(Gtk.Align.START) + + apply_btn = Gtk.Button(label="Uygula") + apply_btn.add_css_class("suggested-action") + self._consume_click(apply_btn) + + reject_btn = Gtk.Button(label="Reddet") + reject_btn.add_css_class("flat") + self._consume_click(reject_btn) + + def _on_apply(_b, _ops=valid_ops, _idx=i): + # çift tıklama vs engelle + 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) + + # UI yenile + self.load_chat() + self.scroll_to_bottom(force=True) + + def _on_reject(_b, _idx=i): + # İstersen "iptal edildi" de gösterebilirsin: + self._set_apply_status(_idx, "cancel") + # self._set_apply_status(_idx, False, "rejected") + 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) + + bubble.append(btn_row) + + # parse_err varsa bile input'a basma; sadece buton göstermemek daha doğru + # (çünkü her bot mesajında parse_err input’u kirletir) + + # Tek seçili balonda action butonları (copy + regenerate) + show_actions = (len(self.selected_indexes) == 1 and i in self.selected_indexes) + + # bubble + buton alanı (spacer) + content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + + btn_space = Gtk.Box() + btn_space.set_size_request(130, -1) # buton alanı (70-120 ayarla) + + if msg.get("role") == "user": + # user sağda → butonlar SOLDA: [space][bubble] + content.append(btn_space) + content.append(bubble) + else: + # bot solda → butonlar SAĞDA: [bubble][space] + content.append(bubble) + content.append(btn_space) + + wrapper = Gtk.Overlay() + wrapper.set_child(content) + + if show_actions: + actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + + copy_btn = Gtk.Button(label="📋") + self._consume_click(copy_btn) + copy_btn.connect("clicked", lambda _b, idx=i: self.copy_message_by_index(idx)) + + regen_btn = Gtk.Button(label="♻") + self._consume_click(regen_btn) + regen_btn.connect("clicked", lambda _b, idx=i: self.regenerate_by_index(idx)) + + actions.append(copy_btn) + actions.append(regen_btn) + + actions.set_valign(Gtk.Align.CENTER) + + # BUTONLAR spacer tarafına yaslanacak: + if msg.get("role") == "user": + actions.set_halign(Gtk.Align.START) # sol spacer içinde kalsın + actions.set_margin_start(0) + else: + actions.set_halign(Gtk.Align.END) # sağ spacer içinde kalsın + actions.set_margin_end(0) + + wrapper.add_overlay(actions) + + row.append(wrapper) + self.chat_box.append(row) + + GLib.idle_add(self.scroll_to_bottom) + + def on_message_clicked(self, index): + self.auto_scroll_enabled = False + if not self.selection_mode: + self.selection_mode = True + self.selected_indexes.add(index) + else: + if index in self.selected_indexes: + self.selected_indexes.remove(index) + else: + self.selected_indexes.add(index) + + if not self.selected_indexes: + self.selection_mode = False + + self.update_selection_label() + self.load_chat() + + def update_selection_label(self): + if not self.selected_indexes: + self.selection_label.set_text("") + self.selection_row.set_opacity(0.0) + self.selection_row.set_sensitive(False) + else: + self.selection_label.set_text(f"{len(self.selected_indexes)} mesaj referans seçildi") + self.selection_row.set_opacity(1.0) + self.selection_row.set_sensitive(True) + + def scroll_to_bottom(self, force=False): + if not self.auto_scroll_enabled and not force: + return + + adj = self.scroll.get_vadjustment() + if adj: + adj.set_value(adj.get_upper() - adj.get_page_size()) + + def on_scroll_button_clicked(self, *_): + self.auto_scroll_enabled = True + self.scroll_to_bottom(force=True) + self.scroll_btn.set_visible(False) + + def on_scroll_changed(self, adjustment): + # en altta mı kontrol et + at_bottom = adjustment.get_value() >= ( + adjustment.get_upper() - adjustment.get_page_size() - 800 + ) + + if at_bottom: + self.scroll_btn.set_visible(False) + else: + self.scroll_btn.set_visible(True) + + # ---------------- VOICE INPUT ------------------- + + def 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.mic_btn.set_tooltip_text("Konuşarak yaz (başlat/durdur)") + + # 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") + + if pw: + cmd = [pw, "--rate", "16000", "--channels", "1", str(self._last_wav)] + elif ar: + cmd = [ar, "-f", "S16_LE", "-r", "16000", "-c", "1", str(self._last_wav)] + else: + self.handle_ai_error("pw-record veya arecord bulunamadı.") + return + + try: + self._voice_proc = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + except Exception as e: + self._voice_proc = None + self.handle_ai_error(f"Ses kaydı başlatılamadı: {e}") + return + + self.mic_btn.set_label("⏹") + self.mic_btn.set_tooltip_text("Kaydı durdur") + + def _record_wav_proc(self, out_path: Path, samplerate=16000, channels=1): + # PipeWire varsa pw-record kullan, yoksa arecord + pw = shutil.which("pw-record") + ar = shutil.which("arecord") + + if pw: + # pw-record --rate 16000 --channels 1 out.wav + cmd = [pw, "--rate", str(samplerate), "--channels", str(channels), str(out_path)] + elif ar: + # arecord -f S16_LE -r 16000 -c 1 out.wav + cmd = [ar, "-f", "S16_LE", "-r", str(samplerate), "-c", str(channels), str(out_path)] + else: + raise RuntimeError("pw-record veya arecord bulunamadı") + + self._voice_proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) + + # stop flag gelene kadar bekle + while not self._rec_stop.is_set(): + GLib.usleep(50_000) # 50ms + + # durdur + try: + self._voice_proc.terminate() + except Exception: + pass + + try: + self._voice_proc.wait(timeout=2) + except Exception: + try: + self._voice_proc.kill() + except Exception: + pass + + def _append_to_input(self, text: str): + buf = self.textview.get_buffer() + it = buf.get_iter_at_mark(buf.get_insert()) + if buf.get_char_count() > 0: + buf.insert(it, " ") + buf.insert(it, text) + + def _transcribe_last_audio(self): + wav = self._last_wav + if not wav or not Path(wav).exists(): + return + + cfg = self.load_config() + is_online = bool(cfg.get("is_mic_online", True)) + + try: + if is_online: + text = self._stt_online_openrouter(wav, cfg) + else: + text = self._stt_offline_whisper_cpp(wav, cfg) + except Exception as e: + GLib.idle_add(self.handle_ai_error, f"STT hata: {e}") + return + + print("STT MODE:", "online" if is_online else "offline") + print("STT RAW TEXT:", repr(text)) + + if text: + GLib.idle_add(self._append_to_input, text.strip()) + + def _stt_online_openrouter(self, wav_path: Path, cfg: dict) -> str: + import requests, json + + key = (cfg.get("open_router_key") or "").strip() + if not key: + raise RuntimeError("open_router_key yok") + + model = (cfg.get("stt_model_online") or "openai/gpt-audio-mini").strip() + + audio_b64 = base64.b64encode(Path(wav_path).read_bytes()).decode("utf-8") + + payload = { + "model": model, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": + "Return ONLY the raw transcription text. No explanations. No prefixes. No extra words." + }, + {"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() + return (data["choices"][0]["message"]["content"] or "").strip() + + def _stt_offline_whisper_cpp(self, wav_path: Path, cfg: dict) -> str: + import tempfile + + bin_path = (cfg.get("whisper_cpp_bin") or "").strip() + model_path = (cfg.get("whisper_cpp_model") or "").strip() + + if not bin_path or not Path(bin_path).exists(): + raise RuntimeError(f"whisper_cpp_bin bulunamadı: {bin_path}") + if not model_path or not Path(model_path).exists(): + raise RuntimeError(f"whisper_cpp_model bulunamadı: {model_path}") + + outdir = Path(tempfile.gettempdir()) / "capture-ai-stt" + outdir.mkdir(parents=True, exist_ok=True) + outbase = outdir / Path(wav_path).stem + + cmd = [ + bin_path, + "-m", model_path, + "-f", str(wav_path), + "-l", "tr", + "-otxt", + "-of", str(outbase), + ] + + p = subprocess.run(cmd, capture_output=True, text=True) + if p.returncode != 0: + raise RuntimeError((p.stderr or p.stdout or "whisper.cpp failed")[:800]) + + txt = Path(str(outbase) + ".txt") + if not txt.exists(): + # bazı sürümlerde .txt yerine stdout'a basabilir—fallback + s = (p.stdout or "").strip() + return s + return txt.read_text(encoding="utf-8", errors="ignore").strip() + + # ---------------- CHANGE CODE FILES ------------- + + def apply_failed_to_input(self, err: str): + if not err: + err = "unknown error" + + buf = self.textview.get_buffer() + # input'u temizle ve hata bas (istersen temizleme, sadece append de yapabilirsin) + buf.set_text(f"❌ APPLY FAILED: {err}") + self.textview.grab_focus() + + def _apply_edit_op(self, file_path: str, find: str, mode: str, text: str) -> tuple[bool, str]: + """ + mode: "before" | "after" | "replace" + find: dosyada bulunacak anchor string + text: eklenecek / değiştirilecek içerik + return: (ok, err) + """ + try: + p = Path(file_path) + if not p.exists(): + return False, f"file not found: {p}" + + original = p.read_text(encoding="utf-8", errors="ignore") + + idx = original.find(find) + if idx < 0: + return False, f"find failed: anchor not found in {p.name}" + + if mode == "before": + new_content = original[:idx] + text + original[idx:] + elif mode == "after": + new_content = original[:idx + len(find)] + text + original[idx + len(find):] + elif mode == "replace": + new_content = original.replace(find, text, 1) # sadece ilk eşleşme + else: + return False, f"unknown mode: {mode}" + + if new_content == original: + return False, "no changes produced (maybe identical content?)" + + p.write_text(new_content, encoding="utf-8") + return True, "" + except Exception as e: + return False, f"exception: {e}" + + def apply_ops_yes(self, ops) -> tuple[bool, str]: + """ + ops: parse edilmiş operasyon listesi + return: (ok, err) + """ + if not isinstance(ops, list) or not ops: + return False, "no ops to apply" + + for op in ops: + path = str(op.get("path") or "").strip() + find = str(op.get("find") or "") + mode = str(op.get("mode") or "").strip().lower() + text = str(op.get("text") or "") + + ok, err = self._apply_edit_op(path, find, mode, text) + if not ok: + return False, err + + return True, "" + + def _set_apply_status(self, msg_index: int, status: str, err: str = ""): + """ + status: "ok" | "fail" | "cancel" + """ + try: + with open(self.current_chat, "r", encoding="utf-8") as f: + messages = json.load(f) or [] + except Exception: + return + + if not (0 <= msg_index < len(messages)): + return + + messages[msg_index]["apply_status"] = { + "status": status, + "err": (err or "").strip() + } + + try: + with open(self.current_chat, "w", encoding="utf-8") as f: + json.dump(messages, f, ensure_ascii=False, indent=2) + except Exception: + return + + def apply_request_yes(self, req: dict): + """ + req örneği: + { + "path": "/home/bob/capture-ai/ui.py", + "find": "def on_attach_clicked", + "mode": "before", # before|after|replace + "text": "...\n" + } + """ + path = str(req.get("path") or "").strip() + find = str(req.get("find") or "") + mode = str(req.get("mode") or "").strip().lower() + text = str(req.get("text") or "") + + # temel doğrulamalar + if not path: + GLib.idle_add(self.apply_failed_to_input, "missing path") + return + if not find: + GLib.idle_add(self.apply_failed_to_input, "missing find (anchor) string") + return + if mode not in ("before", "after", "replace"): + GLib.idle_add(self.apply_failed_to_input, f"invalid mode: {mode}") + return + if text == "": + GLib.idle_add(self.apply_failed_to_input, "missing text") + return + + ok, err = self._apply_edit_op(path, find, mode, text) + if not ok: + GLib.idle_add(self.apply_failed_to_input, err) + return + + # Başarılı olunca istersen input’a kısa bilgi de basabilirsin: + # (chat'e yazma yok) + GLib.idle_add(self._append_to_input, f"✅ Applied to {Path(path).name}") + + + def apply_request_no(self): + # hiçbir şey yapma; input'a da yazmak istemiyorsan boş bırak + return + + # ---------------- SEND / CALL AI ---------------- + + def send_message(self, widget): + buffer = self.textview.get_buffer() + start, end = buffer.get_bounds() + message = buffer.get_text(start, end, True).strip() + if not message: + return + + with open(self.current_chat, "r", encoding="utf-8") as f: + messages = json.load(f) or [] + + new_message = {"role": "user", "content": message} + + if self.pending_images: + new_message["images"] = [str(Path(p).resolve()) for p in self.pending_images if Path(p).exists()] + + if self.pending_files: + new_message["files"] = [] + for f in self.pending_files: + fp = Path(str(f.get("path",""))) + if fp.exists(): + new_message["files"].append({ + "path": str(fp.resolve()), + "name": fp.name, + "edit": bool(f.get("edit")), # <-- önemli + }) + + # referansla gönderildiyse işaretle + # referansla gönderildiyse: indeks + snapshot önizleme kaydet + if self.selected_indexes: + selected = sorted(self.selected_indexes) + + # AI'ya da gitsin diye zinciri genişlet (fotoğraf vs kaybolmasın) + expanded = set() + for ridx in selected: + if 0 <= ridx < len(messages): + expanded |= self.expand_ref_chain(messages, ridx) + + new_message["used_refs"] = sorted(expanded) + + # Görünüm için: her seçilen referans kendi balonu gibi bir "group" olsun + # group = (o mesajın eski refs_preview'si) + (o mesajın kendisi) + groups = [] + for ridx in selected: + if not (0 <= ridx < len(messages)): + continue + rmsg = messages[ridx] + + group_items = [] + + # Eğer bu referans mesajı daha önce referansla gönderildiyse, + # onun içindeki refs_groups'u (veya eski refs_preview'u) da gösterelim + rg = rmsg.get("refs_groups") + if isinstance(rg, list) and rg: + # refs_groups varsa, içindeki tüm item’ları düzleştirip ekleyelim + for g in rg: + items = g.get("items") if isinstance(g, dict) else None + if isinstance(items, list): + for it in items: + if isinstance(it, dict) and it.get("text"): + group_items.append({"role": it.get("role",""), "text": it.get("text","")}) + else: + # Eski sürümden kalan refs_preview varsa onu da ekle + rp = rmsg.get("refs_preview") + if isinstance(rp, list) and rp: + for it in rp: + if isinstance(it, dict) and it.get("text"): + group_items.append({"role": it.get("role",""), "text": it.get("text","")}) + + # en sona referans mesajın kendisini ekle (bağlam bütünlüğü için) + group_items.append(self.pack_preview_item(rmsg)) + + groups.append({"items": group_items}) + + new_message["refs_groups"] = groups + + messages.append(new_message) + + with open(self.current_chat, "w", encoding="utf-8") as f: + json.dump(messages, f, ensure_ascii=False, indent=2) + + buffer.set_text("") + # preview temizle + self.pending_images.clear() + self.pending_files.clear() + self.refresh_attachments_preview() + + selected_arg = "" + if self.selected_indexes: + # aynı expanded used_refs setini yeniden hesapla (ya da new_message["used_refs"]'ten al) + ref_idxs = new_message.get("used_refs") or [] + if isinstance(ref_idxs, list) and ref_idxs: + selected_arg = ",".join(str(i) for i in ref_idxs if isinstance(i, int)) + + self.selected_indexes.clear() + self.selection_mode = False + self.update_selection_label() + + self.load_chat() + self.show_typing_indicator() + + threading.Thread(target=self.call_ai, args=(selected_arg,), daemon=True).start() + + def _consume_click(self, widget: Gtk.Widget): + """ + Bu widget'a tıklanınca click event'ini yukarı (bubble click handler'ına) taşırma. + """ + g = Gtk.GestureClick() + g.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) + + def on_pressed(gesture, n_press, x, y): + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + + g.connect("pressed", on_pressed) + widget.add_controller(g) + + def extract_title_block(self, text: str): + lines = text.splitlines() + + start = None + end = None + + for i, line in enumerate(lines): + if line.strip() == "title": + if start is None: + start = i + else: + end = i + break + + if start is not None and end is not None and end > start: + return "\n".join(lines[start + 1:end]).strip() + + return None + + def extract_copy_block(self, text: str): + lines = text.splitlines() + + start = None + end = None + + for i, line in enumerate(lines): + if line.strip() == "copy": + if start is None: + start = i + else: + end = i + break + + if start is not None and end is not None and end > start: + return "\n".join(lines[start + 1:end]).strip() + + return None + + def extract_apply_block(self, text: str): + lines = text.splitlines() + + start = None + end = None + + for i, line in enumerate(lines): + if line.strip() == "apply": + if start is None: + start = i + else: + end = i + break + + if start is not None and end is not None and end > start: + return "\n".join(lines[start + 1:end]).strip() + + return None + + def parse_apply_ops(self, apply_text: str): + """ + apply bloğunun içindeki JSON’u parse eder. + Kabul edilen formatlar: + - Tek dict: {"path":..., "find":..., "mode":..., "text":...} + - Liste: [{"path":...}, {...}] + Return: (ops_list, err_str) + """ + try: + raw = (apply_text or "").strip() + if not raw: + return None, "empty apply block" + + data = json.loads(raw) + + if isinstance(data, dict): + ops = [data] + elif isinstance(data, list): + ops = data + else: + return None, "apply JSON must be object or array" + + # minimum alan doğrulama + clean = [] + for i, op in enumerate(ops): + if not isinstance(op, dict): + return None, f"op #{i} is not an object" + clean.append(op) + + return clean, "" + except Exception as e: + return None, f"invalid apply JSON: {e}" + + def _short(self, s: str, n: int = 120) -> str: + s = (s or "").replace("\n", "\\n") + return s if len(s) <= n else (s[:n] + "...") + + def build_apply_summary(self, ops: list[dict]) -> str: + """ + ops -> 1 satırlık özet üretir. + (Şimdilik ilk op'u baz alıyoruz) + """ + if not isinstance(ops, list) or not ops or not isinstance(ops[0], dict): + return "" + + op = ops[0] + mode = str(op.get("mode") or "").strip().lower() + find = str(op.get("find") or "") + text = str(op.get("text") or "") + + if mode not in ("replace", "before", "after"): + mode = "replace" + + # TR cümle istersen: + # "Bu satırı replace ediyorum: -> " + return f'Bu satırı {mode} ediyorum: {self._short(find)} -> {self._short(text)}' + + def 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): + # model parametresi göndermiyoruz. + # ai.py, chat_models + ai_models[0] mantığıyla kendisi seçecek. + cmd = [sys.executable, AI_SCRIPT, str(self.current_chat)] + + if selected_arg: + cmd.append(selected_arg) + + try: + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + error_message = result.stderr.strip() or "Bilinmeyen hata" + GLib.idle_add(self.handle_ai_error, error_message) + else: + GLib.idle_add(self.after_ai_response) + + except Exception as e: + GLib.idle_add(self.handle_ai_error, str(e)) if __name__ == "__main__": - if len(sys.argv) != 2: - print("Kullanım: ui.py ") - sys.exit(1) + image_arg = None + if len(sys.argv) >= 2: + image_arg = sys.argv[1] - app = App(sys.argv[1]) + app = ChatApp(image_arg) app.run()