#!/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"{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_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: -> "
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()