3013 lines
104 KiB
Python
3013 lines
104 KiB
Python
#!/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")
|
||
gi.require_version("Adw", "1")
|
||
from gi.repository import Gtk, GLib, Gio, Gdk, Pango, Adw
|
||
Adw.init()
|
||
|
||
CONFIG_PATH = Path.home() / ".config" / "capture-ai" / "config.json"
|
||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
BASE_DIR = Path.home() / ".cache" / "capture-ai"
|
||
CHAT_DIR = BASE_DIR / "chats"
|
||
AI_SCRIPT = str(Path.home() / "capture-ai/ai.py")
|
||
|
||
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
|
||
|
||
self.pending_images = []
|
||
self.pending_files = []
|
||
|
||
if image_path:
|
||
try:
|
||
from urllib.parse import urlparse, unquote
|
||
p = str(image_path).strip()
|
||
|
||
# file://... URI gelirse path’e çevir
|
||
if p.startswith("file://"):
|
||
p = unquote(urlparse(p).path)
|
||
|
||
rp = str(Path(p).expanduser().resolve())
|
||
if Path(rp).exists():
|
||
self.pending_images = [rp]
|
||
except Exception:
|
||
pass
|
||
|
||
self.sidebar_expanded = True
|
||
self.chats_list_open = True
|
||
self.models_list_open = True
|
||
|
||
self.auto_focus_enabled = True
|
||
self.auto_scroll_enabled = True
|
||
self.selected_indexes = set()
|
||
self.selection_mode = False
|
||
|
||
# aktif chat’in modeli (chat başına)
|
||
self.active_model = None
|
||
|
||
# ---------------- CONFIG HELPERS ----------------
|
||
|
||
def load_config(self):
|
||
try:
|
||
if CONFIG_PATH.exists():
|
||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||
return json.load(f) or {}
|
||
except Exception as e:
|
||
print("config read error:", e)
|
||
return {}
|
||
|
||
def save_config(self, cfg: dict):
|
||
try:
|
||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||
except Exception as e:
|
||
print("config save error:", e)
|
||
|
||
def _get_models_list(self, cfg: dict):
|
||
models = cfg.get("ai_models", [])
|
||
if not isinstance(models, list):
|
||
models = []
|
||
# normalize
|
||
models = [str(m).strip() for m in models if str(m).strip()]
|
||
return models
|
||
|
||
def _ensure_min_one_model(self, cfg: dict):
|
||
"""
|
||
Senin isteğin: minimum 1 model kalmalı.
|
||
Eğer config boşsa burada bir default ekliyoruz.
|
||
İstersen bunu hata yapacak şekilde değiştirebilirsin.
|
||
"""
|
||
models = self._get_models_list(cfg)
|
||
if not models:
|
||
models = ["google/gemini-2.5-flash-image"]
|
||
cfg["ai_models"] = models
|
||
self.save_config(cfg)
|
||
return models
|
||
|
||
def get_default_model(self, cfg: dict) -> str:
|
||
models = self._ensure_min_one_model(cfg)
|
||
return str(models[0])
|
||
|
||
def get_chat_model(self, chat_path: Path) -> str:
|
||
"""
|
||
Bu chat için model:
|
||
- cfg.chat_models[chat_name] varsa ve ai_models içinde ise onu döndür
|
||
- yoksa / silinmişse => ai_models[0] + config’e yaz (self-heal)
|
||
"""
|
||
cfg = self.load_config()
|
||
models = self._ensure_min_one_model(cfg)
|
||
|
||
chat_models = cfg.get("chat_models", {})
|
||
if not isinstance(chat_models, dict):
|
||
chat_models = {}
|
||
|
||
key = chat_path.name
|
||
m = str(chat_models.get(key) or "").strip()
|
||
|
||
if (not m) or (m not in models):
|
||
m = str(models[0])
|
||
chat_models[key] = m
|
||
cfg["chat_models"] = chat_models
|
||
self.save_config(cfg)
|
||
|
||
return m
|
||
|
||
def set_chat_model(self, chat_path: Path, model_id: str):
|
||
"""
|
||
Bu chat’in modelini set eder + LRU (en son seçilen model en üste)
|
||
"""
|
||
model_id = str(model_id).strip()
|
||
if not model_id:
|
||
return
|
||
|
||
cfg = self.load_config()
|
||
models = self._ensure_min_one_model(cfg)
|
||
|
||
# LRU: seçileni en üste al
|
||
if model_id not in models:
|
||
models.insert(0, model_id)
|
||
else:
|
||
models = [m for m in models if m != model_id]
|
||
models.insert(0, model_id)
|
||
|
||
cfg["ai_models"] = models
|
||
|
||
chat_models = cfg.get("chat_models", {})
|
||
if not isinstance(chat_models, dict):
|
||
chat_models = {}
|
||
chat_models[chat_path.name] = model_id
|
||
cfg["chat_models"] = chat_models
|
||
|
||
# selected_model artık gereksiz; istersen yazma:
|
||
# cfg["selected_model"] = model_id
|
||
|
||
self.save_config(cfg)
|
||
|
||
# UI state
|
||
self.active_model = model_id
|
||
self.load_models_list()
|
||
self.apply_models_visibility()
|
||
|
||
# ---------------- UI BUILD ----------------
|
||
|
||
def on_activate(self, app):
|
||
self.win = Gtk.ApplicationWindow(application=app)
|
||
self.win.set_title("Capture AI")
|
||
self.win.set_default_size(1100, 750)
|
||
self.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_prev_btn = Gtk.Button(label="<")
|
||
self.preview_prev_btn.set_valign(Gtk.Align.CENTER)
|
||
self.preview_prev_btn.set_vexpand(False)
|
||
self.preview_prev_btn.set_size_request(28, 32)
|
||
self.preview_prev_btn.add_css_class("flat")
|
||
self.preview_prev_btn.set_tooltip_text("Sola kaydır")
|
||
self.preview_prev_btn.connect("clicked", self.scroll_preview_left)
|
||
self.preview_prev_btn.set_visible(False)
|
||
self.preview_row.append(self.preview_prev_btn)
|
||
|
||
self.preview_scroller = Gtk.ScrolledWindow()
|
||
self.preview_scroller.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER)
|
||
self.preview_scroller.set_hexpand(True)
|
||
self.preview_scroller.set_propagate_natural_height(False)
|
||
self.preview_scroller.set_max_content_height(90)
|
||
self.preview_scroller.set_min_content_height(90)
|
||
self.preview_scroller.set_vexpand(False)
|
||
self.preview_scroller.set_valign(Gtk.Align.END)
|
||
|
||
hadj = self.preview_scroller.get_hadjustment()
|
||
if hadj:
|
||
hadj.connect("value-changed", lambda *_: GLib.idle_add(self._update_preview_overflow))
|
||
|
||
self.preview_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
self.preview_hbox.set_halign(Gtk.Align.START)
|
||
self.preview_hbox.set_hexpand(False)
|
||
self.preview_scroller.set_child(self.preview_hbox)
|
||
|
||
self.preview_row.append(self.preview_scroller)
|
||
|
||
self.preview_next_btn = Gtk.Button(label=">")
|
||
self.preview_next_btn.set_valign(Gtk.Align.CENTER)
|
||
self.preview_next_btn.set_vexpand(False)
|
||
self.preview_next_btn.set_size_request(28, 32)
|
||
self.preview_next_btn.add_css_class("flat")
|
||
self.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)
|
||
self.preview_row.append(self.preview_next_btn)
|
||
|
||
self.preview_row.set_visible(False)
|
||
|
||
# başta gizli (chat zıplamasın istersen opacity yaklaşımı da kullanabilirsin)
|
||
self.preview_row.set_visible(False)
|
||
|
||
self.bottom_spacer = Gtk.Box()
|
||
self.bottom_spacer.set_size_request(-1, 110)
|
||
self.content_box.append(self.bottom_spacer)
|
||
|
||
self.scroll = Gtk.ScrolledWindow()
|
||
self.scroll.set_hexpand(True)
|
||
self.scroll.set_vexpand(True)
|
||
self.scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||
self.scroll.set_child(self.content_box)
|
||
overlay.set_child(self.scroll)
|
||
vadj = self.scroll.get_vadjustment()
|
||
vadj.connect("value-changed", self.on_scroll_changed)
|
||
|
||
# ---------------- BOTTOM BAR (PREVIEW + INPUT) ----------------
|
||
bottom_bar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||
bottom_bar.set_hexpand(True)
|
||
bottom_bar.set_halign(Gtk.Align.FILL)
|
||
bottom_bar.set_vexpand(False)
|
||
bottom_bar.set_margin_bottom(12)
|
||
|
||
bottom_bar.set_valign(Gtk.Align.END)
|
||
bottom_bar.set_vexpand(False)
|
||
overlay.add_overlay(bottom_bar)
|
||
|
||
bottom_clamp = Adw.Clamp()
|
||
bottom_clamp.set_hexpand(True)
|
||
bottom_clamp.set_halign(Gtk.Align.FILL)
|
||
bottom_clamp.set_vexpand(False)
|
||
bottom_clamp.props.maximum_size = 800
|
||
bottom_clamp.props.tightening_threshold = 800
|
||
bottom_bar.append(bottom_clamp)
|
||
|
||
bottom_inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||
bottom_inner.set_hexpand(True)
|
||
bottom_inner.set_halign(Gtk.Align.FILL)
|
||
bottom_inner.set_vexpand(False)
|
||
bottom_clamp.set_child(bottom_inner)
|
||
self.bottom_inner = bottom_inner
|
||
|
||
# Preview satırı (input'un üstünde) + input aynı genişlik/paddingte dursun
|
||
pad = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||
pad.set_hexpand(True)
|
||
pad.set_halign(Gtk.Align.FILL)
|
||
pad.set_vexpand(False)
|
||
|
||
# input ile aynı "iç boşluk" hissi için
|
||
pad.set_margin_start(8)
|
||
pad.set_margin_end(8)
|
||
|
||
self.preview_parent = pad
|
||
bottom_inner.append(pad)
|
||
|
||
# Görünmez holder: preview burada duracak (border/back yok)
|
||
self.preview_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||
self.preview_holder.set_hexpand(True)
|
||
self.preview_holder.set_halign(Gtk.Align.FILL)
|
||
self.preview_holder.set_vexpand(False)
|
||
|
||
# input'a çok yakın dursun
|
||
self.preview_holder.set_margin_top(0)
|
||
self.preview_holder.set_margin_bottom(0)
|
||
|
||
pad.append(self.preview_holder)
|
||
|
||
# Preview satırı (holder içinde)
|
||
self.preview_row.set_valign(Gtk.Align.END)
|
||
self.preview_row.set_vexpand(False)
|
||
self.preview_row.set_hexpand(True)
|
||
self.preview_row.set_halign(Gtk.Align.FILL)
|
||
|
||
# ÖNEMLİ: preview_row üzerindeki marginleri 0 tut
|
||
self.preview_row.set_margin_start(0)
|
||
self.preview_row.set_margin_end(0)
|
||
|
||
self.preview_holder.append(self.preview_row)
|
||
|
||
# Input satırı (preview'un altında)
|
||
input_wrapper = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||
input_wrapper.set_hexpand(True)
|
||
input_wrapper.set_halign(Gtk.Align.FILL)
|
||
input_wrapper.set_valign(Gtk.Align.END)
|
||
input_wrapper.set_vexpand(False)
|
||
|
||
pad.append(input_wrapper)
|
||
|
||
# refresh'i pad + preview yerleştikten sonra çağır (senin yaptığın doğru)
|
||
self.refresh_attachments_preview()
|
||
|
||
input_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||
input_row.set_hexpand(True)
|
||
input_row.set_halign(Gtk.Align.FILL)
|
||
input_row.set_vexpand(False)
|
||
input_wrapper.append(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)
|
||
|
||
# (Scroll-to-bottom butonu overlay’de kalabilir; margin_bottom’u input yüksekliğine göre ayarla)
|
||
self.scroll_btn = Gtk.Button(label="↓")
|
||
self.scroll_btn.set_size_request(36, 36)
|
||
self.scroll_btn.set_valign(Gtk.Align.END)
|
||
self.scroll_btn.set_halign(Gtk.Align.END)
|
||
self.scroll_btn.set_margin_bottom(110) # preview+input ü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)
|
||
|
||
# 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;
|
||
}
|
||
|
||
.attach-x {
|
||
padding: 0px;
|
||
margin: 0px;
|
||
border-radius: 999px;
|
||
min-width: 18px;
|
||
min-height: 18px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.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 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):
|
||
try:
|
||
# Preview her zaman preview_holder içinde kalsın
|
||
holder = getattr(self, "preview_holder", None)
|
||
if holder and self.preview_row:
|
||
parent = self.preview_row.get_parent()
|
||
if parent is not holder:
|
||
if parent is not None:
|
||
try:
|
||
parent.remove(self.preview_row)
|
||
except Exception:
|
||
pass
|
||
holder.append(self.preview_row)
|
||
except Exception:
|
||
pass
|
||
|
||
was_at_bottom = self.is_at_bottom()
|
||
# temizle
|
||
if not hasattr(self, "preview_hbox"):
|
||
return
|
||
|
||
while True:
|
||
c = self.preview_hbox.get_first_child()
|
||
if not c:
|
||
break
|
||
self.preview_hbox.remove(c)
|
||
|
||
has_any = bool(self.pending_images or self.pending_files)
|
||
self.preview_row.set_visible(has_any)
|
||
if not has_any:
|
||
if hasattr(self, "preview_next_btn"):
|
||
self.preview_next_btn.set_visible(False)
|
||
return
|
||
|
||
# görseller
|
||
for p in list(self.pending_images):
|
||
img_path = Path(p)
|
||
if not img_path.exists():
|
||
continue
|
||
|
||
# Kart içeriği
|
||
item = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||
item.set_halign(Gtk.Align.START)
|
||
item.set_hexpand(False)
|
||
item.set_vexpand(False)
|
||
item.set_size_request(110, -1)
|
||
|
||
file = Gio.File.new_for_path(str(img_path.resolve()))
|
||
pic = Gtk.Picture.new_for_file(file)
|
||
pic.set_can_shrink(True)
|
||
pic.set_keep_aspect_ratio(True)
|
||
pic.set_content_fit(Gtk.ContentFit.COVER)
|
||
pic.set_size_request(96, 72)
|
||
pic.set_hexpand(False)
|
||
pic.set_vexpand(False)
|
||
pic.set_halign(Gtk.Align.START)
|
||
|
||
name = Gtk.Label(label=img_path.name)
|
||
name.set_xalign(0)
|
||
name.set_hexpand(False)
|
||
name.set_halign(Gtk.Align.START)
|
||
name.set_wrap(False)
|
||
name.set_single_line_mode(True)
|
||
name.set_max_width_chars(18)
|
||
name.set_ellipsize(Pango.EllipsizeMode.END)
|
||
|
||
item.append(pic)
|
||
item.append(name)
|
||
|
||
# Overlay: sağ üst X
|
||
overlay_item = Gtk.Overlay()
|
||
overlay_item.set_child(item)
|
||
|
||
xbtn = Gtk.Button(label="✕")
|
||
xbtn.add_css_class("flat")
|
||
xbtn.set_focusable(False)
|
||
xbtn.set_halign(Gtk.Align.END)
|
||
xbtn.set_valign(Gtk.Align.START)
|
||
xbtn.set_margin_top(2)
|
||
xbtn.set_margin_end(2)
|
||
xbtn.set_size_request(22, 22)
|
||
|
||
self._consume_click(xbtn)
|
||
|
||
def _rm_img(_b, path=str(img_path.resolve())):
|
||
try:
|
||
if path in self.pending_images:
|
||
self.pending_images.remove(path)
|
||
except Exception:
|
||
pass
|
||
self.refresh_attachments_preview()
|
||
|
||
xbtn.connect("clicked", _rm_img)
|
||
overlay_item.add_overlay(xbtn)
|
||
|
||
self.preview_hbox.append(overlay_item)
|
||
|
||
# belgeler
|
||
for f in self.pending_files:
|
||
p = Path(f.get("path",""))
|
||
if not p.exists():
|
||
continue
|
||
|
||
chip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||
chip.add_css_class("attach-chip")
|
||
chip.set_hexpand(False)
|
||
chip.set_halign(Gtk.Align.START)
|
||
|
||
if f.get("edit"):
|
||
chip.add_css_class("attach-edit-on")
|
||
|
||
icon = Gtk.Image.new_from_icon_name("text-x-generic-symbolic")
|
||
chip.append(icon)
|
||
|
||
lab = Gtk.Label(label=p.name)
|
||
lab.set_xalign(0)
|
||
lab.set_max_width_chars(26)
|
||
lab.set_ellipsize(Pango.EllipsizeMode.END)
|
||
lab.set_single_line_mode(True)
|
||
lab.set_hexpand(False)
|
||
lab.set_halign(Gtk.Align.START)
|
||
lab.set_wrap(False)
|
||
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)
|
||
|
||
overlay_chip = Gtk.Overlay()
|
||
overlay_chip.set_child(chip)
|
||
|
||
xbtn = Gtk.Button(label="✕")
|
||
xbtn.add_css_class("attach-x")
|
||
xbtn.set_focusable(False)
|
||
xbtn.set_halign(Gtk.Align.END)
|
||
xbtn.set_valign(Gtk.Align.START)
|
||
xbtn.set_margin_top(2)
|
||
xbtn.set_margin_end(2)
|
||
xbtn.set_size_request(22, 22)
|
||
|
||
self._consume_click(xbtn)
|
||
|
||
def _rm_file(_b, path=str(p.resolve())):
|
||
try:
|
||
self.pending_files = [x for x in self.pending_files if str(Path(x.get("path","")).resolve()) != path]
|
||
except Exception:
|
||
pass
|
||
self.refresh_attachments_preview()
|
||
|
||
xbtn.connect("clicked", _rm_file)
|
||
overlay_chip.add_overlay(xbtn)
|
||
|
||
self.preview_hbox.append(overlay_chip)
|
||
|
||
# taşma var mı? (butonu ona göre aç/kapat)
|
||
GLib.idle_add(self._update_preview_overflow)
|
||
|
||
if was_at_bottom:
|
||
GLib.idle_add(self.scroll_to_bottom, True)
|
||
|
||
GLib.idle_add(self._sync_scroll_btn_margin)
|
||
|
||
def _update_preview_overflow(self):
|
||
try:
|
||
hadj = self.preview_scroller.get_hadjustment()
|
||
if not hadj:
|
||
return False
|
||
|
||
upper = hadj.get_upper()
|
||
page = hadj.get_page_size()
|
||
val = hadj.get_value()
|
||
|
||
# taşma var mı?
|
||
overflow = upper > page + 2
|
||
|
||
# sola kaydırılabiliyor mu?
|
||
can_left = overflow and (val > 2)
|
||
|
||
# sağa kaydırılabiliyor mu?
|
||
max_val = max(0, upper - page)
|
||
can_right = overflow and (val < max_val - 2)
|
||
|
||
if hasattr(self, "preview_prev_btn"):
|
||
self.preview_prev_btn.set_visible(bool(can_left))
|
||
if hasattr(self, "preview_next_btn"):
|
||
self.preview_next_btn.set_visible(bool(can_right))
|
||
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
def scroll_preview_right(self, *_):
|
||
try:
|
||
hadj = self.preview_scroller.get_hadjustment()
|
||
if not hadj:
|
||
return
|
||
step = max(120, hadj.get_page_size() * 0.75)
|
||
hadj.set_value(min(hadj.get_upper() - hadj.get_page_size(), hadj.get_value() + step))
|
||
except Exception:
|
||
pass
|
||
|
||
def scroll_preview_left(self, *_):
|
||
try:
|
||
hadj = self.preview_scroller.get_hadjustment()
|
||
if not hadj:
|
||
return
|
||
step = max(120, hadj.get_page_size() * 0.75)
|
||
hadj.set_value(max(0, hadj.get_value() - step))
|
||
except Exception:
|
||
pass
|
||
|
||
# ---------------- AI ERROR / TYPING ----------------
|
||
|
||
def handle_ai_error(self, error_text):
|
||
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"<b>{safe}</b>")
|
||
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_can_shrink(True)
|
||
pic.set_keep_aspect_ratio(True)
|
||
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 is_at_bottom(self, slack=6):
|
||
adj = self.scroll.get_vadjustment() if hasattr(self, "scroll") else None
|
||
if not adj:
|
||
return True
|
||
return adj.get_value() >= (adj.get_upper() - adj.get_page_size() - slack)
|
||
|
||
def _sync_scroll_btn_margin(self):
|
||
try:
|
||
h = self.preview_row.get_allocated_height() if self.preview_row.get_visible() else 0
|
||
# input + padding için kaba bir ek
|
||
self.scroll_btn.set_margin_bottom(80 + h)
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
def scroll_to_bottom(self, force=False):
|
||
if not self.auto_scroll_enabled and not force:
|
||
return
|
||
|
||
adj = self.scroll.get_vadjustment()
|
||
if adj:
|
||
adj.set_value(adj.get_upper() - adj.get_page_size())
|
||
|
||
def on_scroll_button_clicked(self, *_):
|
||
self.auto_scroll_enabled = True
|
||
self.scroll_to_bottom(force=True)
|
||
self.scroll_btn.set_visible(False)
|
||
|
||
def on_scroll_changed(self, adjustment):
|
||
# en altta mı kontrol et
|
||
at_bottom = adjustment.get_value() >= (
|
||
adjustment.get_upper() - adjustment.get_page_size() - 800
|
||
)
|
||
|
||
if at_bottom:
|
||
self.scroll_btn.set_visible(False)
|
||
else:
|
||
self.scroll_btn.set_visible(True)
|
||
|
||
# ---------------- VOICE INPUT -------------------
|
||
|
||
def 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: <find> -> <text>"
|
||
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__":
|
||
image_arg = None
|
||
if len(sys.argv) >= 2:
|
||
image_arg = sys.argv[1]
|
||
|
||
app = ChatApp(image_arg)
|
||
app.run()
|