diff --git a/ai.py b/ai.py index aa6b0b3..12aa2ec 100644 --- a/ai.py +++ b/ai.py @@ -3,94 +3,86 @@ import os import sys import json import base64 +import io +import re +import tempfile import mimetypes +import subprocess import shutil import argparse from pathlib import Path from datetime import datetime, timezone from typing import NoReturn, Any, Dict +from memory import ( + load_chat_data, + save_chat_data, + get_chat_messages, + add_memory_chunk, + update_code_context_from_message, + build_memory_context, + should_update_summary, + make_simple_summary, +) import requests CONFIG_PATH = Path.home() / ".config" / "capture-ai" / "config.json" CACHE_BASE = Path.home() / ".cache" / "capture-ai" REFS_CACHE_DIR = CACHE_BASE / "refs" +GENERATED_FILES_DIR = CACHE_BASE / "generated_files" +GENERATED_FILES_DIR.mkdir(parents=True, exist_ok=True) +LANG_DIR = Path.home() / "capture-ai" / "language" + +_LANG_CACHE = None +_LANG_CACHE_LANG = None OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" -GLOBAL_SYSTEM_PROMPT = ( - "Bu mesaj bir talimat setidir." - "Bu mesaja cevap verme." - "Sadece kullanıcı mesajlarına cevap ver.\n" - "Bu konuşmada sana zaman zaman e-posta, kod, komut, yapılandırma dosyası " - "veya birebir kopyalanması gereken metinler yazdırılabilir.\n\n" - "FORMAT KURALI:\n" - "Eğer kullanıcıdan gelen istekte doğrudan kopyalanabilir bir içerik " - "(örneğin kod, script, terminal komutu, yapılandırma dosyası vb.) " - "üretmen gerekiyorsa aşağıdaki formatı kullan:\n\n" +PROMPT_BLOCK_KEYS = { + "copyable": "o_Prompt_Copyable", + "apply": "o_Prompt_Apply", + "file_create": "o_Prompt_File_Create", + "web_search": "o_Prompt_Web_Search", + "structured": "o_Prompt_Structured", + "code": "o_Prompt_Code", +} - "title\n" - "\n" - "title\n" - "copy\n" - "\n" - "copy\n\n" +PDF_RULE_KEYS = { + "pdf_image": "o_Prompt_PDF_Image_Return_Rule", + "pdf_text_only_to_docx": "o_Prompt_PDF_Text_Only_To_DOCX_Rule", + "pdf_mixed_separate": "o_Prompt_PDF_Mixed_Separate_Rule", + "pdf_mixed_text_only": "o_Prompt_PDF_Mixed_Text_Only_Rule", +} - "Kurallar:\n" - "- 'title' ve 'copy' kelimeleri tek başına satır olmalıdır.\n" - "- 'title' bloğu kod bloğundan önce gelmelidir.\n" - "- Kod yoksa bu formatı kullanma.\n" - "- Normal sohbet cevaplarında bu formatı kullanma.\n" - "- Sadece gerçekten kopyalanabilir içerik varsa bu formatı kullan.\n" - "\n\n[FILE] BLOKLARI HAKKINDA:\n" - "- Eğer kullanıcı bir dosya eklediyse, dosya içeriği bu mesaj içinde " - "[FILE] ... [CONTENT] ... [/CONTENT] formatında ZATEN verilmiştir.\n" - "- Bu durumda kullanıcıdan 'kodu buraya yapıştır' veya 'dosyayı paylaş' deme.\n" - "- Değişiklik gerekiyorsa [CONTENT] içeriğini okuyarak işlem yap.\n" - "- Dosya içeriğini tekrar isteme.\n" - "- path alanı olarak [FILE] bloğunda verilen path değerini aynen kullan.\n" +def get_prompt_text(cfg: dict, key: str) -> str: + text = get_ui_text(cfg, key) + return "" if text == key else str(text).strip() - "\n\nDOSYA DEĞİŞİKLİĞİ KURALI:\n" - "- [FILE] bloklarında 'editable: true' ise dosyada değişiklik önerebilirsin.\n" - "- 'editable: false' olan dosyalar için ASLA dosyaya uygulanacak değişiklik isteme.\n" - "- editable:false dosyalar için apply bloğu üretme.\n" - "- Eğer değişiklik gerekmiyorsa apply bloğu hiç koyma.\n" - "\n\nAPPLY (OTOMATİK UYGULAMA) PROTOKOLÜ:\n" - "Eğer ve sadece eğer kullanıcı 'editable: true' bir dosyada değişiklik istiyorsa,\n" - "cevabının SONUNA aşağıdaki blok formatıyla ekle (başka yerde kullanma):\n\n" +def build_global_system_prompt(selected_blocks, cfg: dict) -> str: + if not isinstance(selected_blocks, list): + selected_blocks = ["copyable"] - "apply\n" - "[\n" - " {\n" - " \"path\": \"/absolute/path/to/file\",\n" - " \"find\": \"DOSYADA BİREBİR GEÇEN ANCHOR METİN\",\n" - " \"mode\": \"before|after|replace\",\n" - " \"text\": \"EKLENECEK/YERİNE GEÇECEK METİN\"\n" - " }\n" - "]\n" - "apply\n\n" + parts = [] - "Kurallar:\n" - "- 'apply' kelimesi tek başına satır olmalı (başlangıç ve bitiş).\n" - "- apply bloğunun içi SADECE JSON olmalı (başka açıklama koyma).\n" - "- JSON sadece object/array olmalı, yorum (//) veya trailing virgül olamaz.\n" - "- mode sadece: before, after, replace.\n" - "- path MUTLAKA absolute path olmalı.\n" - "- find, dosyada birebir bulunan kısa/orta bir anchor olmalı.\n" - "- text, eklenecek içerik ve sonunda '\\n' gerekiyorsa ekle.\n" - "\n\nAPPLY ÖZET SATIRI KURALI:\n" - "- Eğer apply bloğu üretiyorsan, 'Başarıyla yaptım' gibi kesin/bitmiş ifade ASLA kullanma.\n" - "- apply bloğundan ÖNCE sadece 1 satır özet yaz.\n" - "- Özet formatı TAM olarak şu olsun:\n" - " 'Bu satırı ediyorum: -> '\n" - " MODE: replace|before|after\n" - "- ve çok uzunsa 120 karakterde kes ve '...' ekle.\n" - "- Özet dışında ekstra açıklama yazma.\n" - "- Buraya kadar olan mesajlar talimat setidir, cevap verme. Sadece en son kullanıcı mesajını yanıtla." -) + base = get_prompt_text(cfg, "o_Base_System_Prompt") + if base: + parts.append(base) + + for block_name in selected_blocks: + block_name = str(block_name or "").strip() + lang_key = PROMPT_BLOCK_KEYS.get(block_name) + + if not lang_key: + continue + + block_text = get_prompt_text(cfg, lang_key) + if block_text: + parts.append(block_text) + + return "\n\n".join(parts).strip() def load_config(): try: @@ -101,6 +93,177 @@ def load_config(): print("config read error:", e) return {} +def _read_ui_language_from_config() -> str: + try: + if CONFIG_PATH.exists(): + raw = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) or {} + return str(raw.get("ui_language", "en")).strip().lower() or "en" + except Exception: + pass + return "en" + +def _load_language_map(lang: str) -> dict: + global _LANG_CACHE, _LANG_CACHE_LANG + + lang = str(lang or "").strip().lower() or "en" + + if _LANG_CACHE is not None and _LANG_CACHE_LANG == lang: + return _LANG_CACHE + + lang_path = LANG_DIR / f"{lang}.json" + fallback_path = LANG_DIR / "en.json" + + data = {} + + try: + if lang_path.exists(): + raw = json.loads(lang_path.read_text(encoding="utf-8")) or {} + if isinstance(raw, dict): + data = raw + except Exception as e: + print(f"language file read error ({lang_path.name}):", e) + + if not data: + try: + if fallback_path.exists(): + raw = json.loads(fallback_path.read_text(encoding="utf-8")) or {} + if isinstance(raw, dict): + data = raw + except Exception as e: + print(f"fallback language file read error ({fallback_path.name}):", e) + + if not isinstance(data, dict): + data = {} + + _LANG_CACHE = data + _LANG_CACHE_LANG = lang + return _LANG_CACHE + +def get_ui_text(cfg: dict, key: str, **kwargs) -> str: + lang = str((cfg or {}).get("ui_language", "")).strip().lower() + if not lang: + lang = _read_ui_language_from_config() + + lang_map = _load_language_map(lang) + + if not lang_map: + return key + + text = str(lang_map.get(key, key)) + + try: + return text.format(**kwargs) + except Exception: + return text + +# ---------------------WEB SEARCH--------------------- +def _tavily_search(cfg: dict, query: str, max_results: int = 5) -> str: + api_key = str(cfg.get("tavily_api_key") or "").strip() + if not api_key: + return "[WEB_SEARCH_ERROR]\nTavily API key is missing.\n[/WEB_SEARCH_ERROR]" + + query = str(query or "").strip() + if not query: + return "[WEB_SEARCH_ERROR]\nEmpty search query.\n[/WEB_SEARCH_ERROR]" + + try: + r = requests.post( + "https://api.tavily.com/search", + headers={ + "Content-Type": "application/json" + }, + json={ + "api_key": api_key, + "query": query, + "search_depth": "basic", + "max_results": max_results, + "include_answer": False, + "include_raw_content": False + }, + timeout=45 + ) + + if r.status_code != 200: + try: + err_json = r.json() + err_text = ( + err_json.get("detail") + or err_json.get("error") + or err_json.get("message") + or "" + ) + except Exception: + err_text = "" + + if r.status_code in (401, 403): + msg = get_ui_text( + cfg, + "o_Web_Search_API_Key_Invalid" + ) + + else: + msg = get_ui_text( + cfg, + "o_Web_Search_API_Error", + status_code=r.status_code + ) + + if err_text: + msg += f"\n{err_text}" + + return ( + "[WEB_SEARCH_ERROR]\n" + f"{msg}\n" + "[/WEB_SEARCH_ERROR]" + ) + + data = r.json() + results = data.get("results") or [] + + lines = ["[WEB_SEARCH_RESULTS]", f"query: {query}"] + + for i, item in enumerate(results[:max_results], start=1): + title = str(item.get("title") or "").strip() + url = str(item.get("url") or "").strip() + content = str(item.get("content") or "").strip() + + lines.append(f"\n{i}. {title}") + lines.append(f"url: {url}") + lines.append(f"snippet: {content}") + + lines.append("[/WEB_SEARCH_RESULTS]") + return "\n".join(lines) + + except Exception as e: + return f"[WEB_SEARCH_ERROR]\n{e}\n[/WEB_SEARCH_ERROR]" + +def _extract_web_search_call(text: str) -> str | None: + raw = str(text or "").strip() + + try: + data = json.loads(raw) + except Exception: + m = re.search(r"\{.*?\"tool\"\s*:\s*\"web_search\".*?\}", raw, re.S) + if not m: + return None + + try: + data = json.loads(m.group(0)) + except Exception: + return None + + if not isinstance(data, dict): + return None + + if str(data.get("tool") or "").strip() != "web_search": + return None + + query = str(data.get("query") or "").strip() + return query or None + + + + def _safe_read_text(path: Path, max_bytes: int = 250_000) -> str: try: data = path.read_bytes() @@ -110,6 +273,456 @@ def _safe_read_text(path: Path, max_bytes: int = 250_000) -> str: except Exception as e: return f"[Dosya okunamadı: {e}]" +def _safe_read_pdf(path: Path, max_chars: int = 50000) -> str: + try: + from pypdf import PdfReader + + reader = PdfReader(str(path)) + parts = [] + + for i, page in enumerate(reader.pages[:20], start=1): + txt = (page.extract_text() or "").strip() + if txt: + parts.append(f"\n--- PDF PAGE {i} ---\n{txt}") + + return "\n".join(parts).strip()[:max_chars] + + except Exception as e: + print("pdf text read error:", e, file=sys.stderr) + return "" + +def _looks_like_empty_pdf_text(text: str) -> bool: + t = (text or "").strip() + return len(t) < 80 + +def _user_wants_image_edit(text: str) -> bool: + t = str(text or "").lower() + + image_words = [ + "image", "picture", "photo", "graphic", "diagram", "visual", + "resim", "görsel", "gorsel", "fotoğraf", "fotograf", + "şekil", "sekil", "grafik", "diyagram" + ] + + edit_words = [ + "change", "replace", "modify", "edit", "remove", "add", + "değiştir", "degistir", "düzenle", "duzenle", + "kaldır", "kaldir", "ekle", "yenile" + ] + + return any(w in t for w in image_words) and any(w in t for w in edit_words) + + +def _user_wants_text_edit(text: str) -> bool: + t = str(text or "").lower() + + text_words = [ + "text", "writing", "font", "title", "heading", "paragraph", + "yazı", "yazi", "metin", "font", "başlık", "baslik", + "paragraf", "içerik", "icerik" + ] + + edit_words = [ + "shorten", "minimal", "rewrite", "summarize", "increase", "make bigger", + "kısalt", "kisalt", "minimal", "özetle", "ozetle", + "büyüt", "buyut", "düzenle", "duzenle" + ] + + return any(w in t for w in text_words) and any(w in t for w in edit_words) + +def _analyze_pdf_kind(path: Path) -> dict: + """ + PDF türünü ayırır: + - text_only + - image_only + - mixed + + PyMuPDF çalışmazsa pypdf fallback kullanır. + """ + # 1) Önce PyMuPDF dene + try: + import fitz + + doc = fitz.open(str(path)) + + text_parts = [] + image_count = 0 + + for page in doc: + txt = (page.get_text("text") or "").strip() + if txt: + text_parts.append(txt) + + image_count += len(page.get_images(full=True)) + + doc.close() + + text = "\n\n".join(text_parts).strip() + has_text = len(text) >= 80 + has_images = image_count > 0 + + if has_text and has_images: + kind = "mixed" + elif has_text: + kind = "text_only" + else: + kind = "image_only" + + return { + "kind": kind, + "text": text, + "has_text": has_text, + "has_images": has_images, + "image_count": image_count, + } + + except Exception as e: + print("pdf analyze fitz error:", e, file=sys.stderr) + + # 2) Fallback: pypdf ile sadece text var mı bak + try: + text = _safe_read_pdf(path) + has_text = len((text or "").strip()) >= 80 + + if has_text: + # Görsel var mı kesin bilemeyiz, ama en azından image_only'e düşürmeyiz. + return { + "kind": "text_only", + "text": text, + "has_text": True, + "has_images": False, + "image_count": 0, + } + + except Exception as e: + print("pdf analyze pypdf fallback error:", e, file=sys.stderr) + + return { + "kind": "image_only", + "text": "", + "has_text": False, + "has_images": False, + "image_count": 0, + } + + +def _extract_pdf_mixed_layout(path: Path) -> dict: + """ + Mixed PDF için text ve image bloklarını koordinatlarıyla çıkarır. + Image bloklarını ayrıca dosya olarak kaydeder. + """ + try: + import fitz + + doc = fitz.open(str(path)) + + image_dir = GENERATED_FILES_DIR / "pdf_layout_images" / path.stem + image_dir.mkdir(parents=True, exist_ok=True) + + layout = { + "source_path": str(path.resolve()), + "pages": [], + } + + for page_index, page in enumerate(doc): + page_dict = page.get_text("dict") + page_rect = page.rect + + page_info = { + "page": page_index + 1, + "width": float(page_rect.width), + "height": float(page_rect.height), + "blocks": [], + } + + for block_index, block in enumerate(page_dict.get("blocks", [])): + btype = block.get("type") + bbox = block.get("bbox") + + if not bbox or len(bbox) != 4: + continue + + block_id = f"p{page_index + 1}_b{block_index}" + + # Text block + if btype == 0: + text_parts = [] + + for line in block.get("lines", []): + for span in line.get("spans", []): + t = str(span.get("text") or "") + if t.strip(): + text_parts.append(t) + + text = " ".join(text_parts).strip() + + if text: + page_info["blocks"].append({ + "id": block_id, + "type": "text", + "bbox": bbox, + "text": text, + }) + + # Image block + elif btype == 1: + image_bytes = block.get("image") + ext = str(block.get("ext") or "png").lower().strip() or "png" + + if image_bytes: + img_path = image_dir / f"{block_id}.{ext}" + img_path.write_bytes(image_bytes) + + page_info["blocks"].append({ + "id": block_id, + "type": "image", + "bbox": bbox, + "path": str(img_path.resolve()), + }) + + layout["pages"].append(page_info) + + doc.close() + return layout + + except Exception as e: + print("mixed pdf layout extract error:", e, file=sys.stderr) + return { + "source_path": str(path), + "pages": [], + } + + +def _create_pdf_from_mixed_layout( + layout: dict, + text_replacements: dict | None, + image_replacements: dict | None, + output_name: str = "edited-mixed-pdf.pdf" +) -> Path | None: + """ + Mixed PDF'i yeniden kurar: + - Eski resimleri eski koordinatlarına koyar. + - AI'den gelen text_replacements ile textleri değiştirir. + """ + try: + import fitz + + text_replacements = text_replacements if isinstance(text_replacements, dict) else {} + image_replacements = image_replacements if isinstance(image_replacements, dict) else {} + + out = _generated_output_path(output_name, "pdf") + new_doc = fitz.open() + + for page_info in layout.get("pages", []): + width = float(page_info.get("width") or 595) + height = float(page_info.get("height") or 842) + + page = new_doc.new_page(width=width, height=height) + + # Önce resimler + for block in page_info.get("blocks", []): + if block.get("type") != "image": + continue + + bbox = block.get("bbox") + if not bbox or len(bbox) != 4: + continue + + block_id = str(block.get("id") or "") + img_path = str(image_replacements.get(block_id) or block.get("path") or "") + + if not img_path or not Path(img_path).exists(): + continue + + try: + page.insert_image(fitz.Rect(*bbox), filename=img_path) + except Exception as e: + print("mixed insert image error:", e, file=sys.stderr) + + # Sonra textler + for block in page_info.get("blocks", []): + if block.get("type") != "text": + continue + + bbox = block.get("bbox") + if not bbox or len(bbox) != 4: + continue + + block_id = str(block.get("id") or "") + old_text = str(block.get("text") or "") + new_text = str(text_replacements.get(block_id, old_text) or "") + + try: + page.insert_textbox( + fitz.Rect(*bbox), + new_text, + fontsize=11, + fontname="helv", + color=(0, 0, 0), + align=0, + ) + except Exception as e: + print("mixed insert text error:", e, file=sys.stderr) + + new_doc.save(str(out)) + new_doc.close() + + return out if out.exists() else None + + except Exception as e: + print("mixed pdf create error:", e, file=sys.stderr) + return None + + +def _pdf_to_page_images(path: Path, max_pages: int = 1, zoom: float = 1.5) -> list[Path]: + out_dir = Path(tempfile.gettempdir()) / "capture-ai-pdf-pages" / path.stem + out_dir.mkdir(parents=True, exist_ok=True) + + # Eski cache kalıntılarını temizle + for old in out_dir.glob("page*"): + try: + old.unlink() + except Exception: + pass + + errors = [] + + # 1) PyMuPDF / fitz ile dene + try: + import fitz + + doc = fitz.open(str(path)) + out = [] + + page_count = min(len(doc), max_pages) + + if page_count <= 0: + errors.append("fitz: PDF has 0 pages") + else: + for i in range(page_count): + page = doc[i] + + pix = page.get_pixmap( + matrix=fitz.Matrix(zoom, zoom), + alpha=False + ) + + img_path = out_dir / f"page_{i + 1}.png" + pix.save(str(img_path)) + + if img_path.exists() and img_path.stat().st_size > 0: + out.append(img_path) + else: + errors.append(f"fitz: page {i + 1} produced empty PNG") + + doc.close() + + if out: + return out + + except Exception as e: + errors.append(f"fitz: {e}") + + # 2) Fallback: poppler / pdftoppm ile dene + try: + pdftoppm = shutil.which("pdftoppm") + + if not pdftoppm: + errors.append("pdftoppm: command not found") + else: + prefix = out_dir / "page" + + cmd = [ + pdftoppm, + "-png", + "-r", "150", + "-f", "1", + "-l", str(max_pages), + str(path), + str(prefix), + ] + + proc = subprocess.run( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True + ) + + if proc.returncode != 0: + errors.append(f"pdftoppm: {proc.stderr.strip()}") + else: + out = sorted(out_dir.glob("page-*.png")) + out = [ + p for p in out + if p.exists() and p.stat().st_size > 0 + ] + + if out: + return out[:max_pages] + + errors.append("pdftoppm: no PNG output") + + except Exception as e: + errors.append(f"pdftoppm: {e}") + + print("PDF_CONVERT_FAILED:", " | ".join(errors), file=sys.stderr) + return [] + +def _safe_read_docx(path: Path, max_chars: int = 50000) -> str: + try: + import docx + + doc = docx.Document(str(path)) + parts = [] + + for p in doc.paragraphs: + t = (p.text or "").strip() + if t: + parts.append(t) + + # tabloları da oku + for table in doc.tables: + for row in table.rows: + cells = [] + for cell in row.cells: + txt = (cell.text or "").strip() + if txt: + cells.append(txt) + if cells: + parts.append(" | ".join(cells)) + + out = "\n".join(parts).strip() + return out[:max_chars] if out else "[DOCX file is empty or text could not be extracted.]" + + except Exception as e: + return f"[DOCX okunamadı: {e}]" + +def _safe_read_xlsx(path: Path, max_rows: int = 200, max_chars: int = 50000) -> str: + try: + from openpyxl import load_workbook + + wb = load_workbook(str(path), data_only=True) + parts = [] + + for ws in wb.worksheets: + parts.append(f"\n--- SHEET: {ws.title} ---") + + row_count = 0 + for row in ws.iter_rows(values_only=True): + vals = ["" if v is None else str(v) for v in row] + if any(v.strip() for v in vals): + parts.append(" | ".join(vals)) + row_count += 1 + + if row_count >= max_rows: + parts.append("[Sheet truncated]") + break + + return "\n".join(parts).strip()[:max_chars] + + except Exception as e: + return f"[XLSX okunamadı: {e}]" def _is_text_file(path: Path) -> bool: ext = path.suffix.lower() @@ -127,12 +740,12 @@ def _die(msg: str, code: int = 1) -> NoReturn: def _load_config() -> Dict[str, Any]: if not CONFIG_PATH.exists(): - _die("Config dosyası bulunamadı.", 1) + _die(get_ui_text({}, "o_Config_Not_Found"), 1) try: with open(CONFIG_PATH, "r", encoding="utf-8") as f: return json.load(f) or {} except Exception as e: - _die(f"Config okunamadı: {e}", 1) + _die(get_ui_text({}, "o_Config_Read_Error", error=e), 1) def _save_config(cfg: dict): try: @@ -195,8 +808,731 @@ def _copy_to_cache(src: Path, dest_dir: Path) -> Path: shutil.copy2(src, dest) return dest +def _extract_named_block(text: str, name: str) -> str | None: + pattern = rf"(?ms)^\s*{re.escape(name)}\s*\n(.*?)\n\s*{re.escape(name)}\s*$" + m = re.search(pattern, text or "") + return m.group(1).strip() if m else None -def _build_blocks_and_cache_info(msg: dict, cache_images_dir: Path | None): + +def _remove_named_block(text: str, name: str) -> str: + pattern = rf"(?ms)\n?\s*{re.escape(name)}\s*\n.*?\n\s*{re.escape(name)}\s*" + return re.sub(pattern, "", text or "").strip() + + +def _safe_output_name(name: str, fallback_ext: str) -> str: + name = str(name or "").strip() + + if not name: + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + name = f"generated-{stamp}.{fallback_ext}" + + for ch in ["/", "\\", "\n", "\r", "\t"]: + name = name.replace(ch, "_") + + if "." not in Path(name).name: + name += f".{fallback_ext}" + + return name + + +def _generated_output_path(output_name: str, fallback_ext: str) -> Path: + GENERATED_FILES_DIR.mkdir(parents=True, exist_ok=True) + + safe_name = _safe_output_name(output_name, fallback_ext) + out = GENERATED_FILES_DIR / safe_name + + if out.exists(): + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + out = GENERATED_FILES_DIR / f"{out.stem}-{stamp}{out.suffix}" + + return out + +def _message_content_with_generated_files(msg: dict) -> str: + text = str(msg.get("content") or "").strip() + + lines = [] + if text: + lines.append(text) + + images = msg.get("images") + if isinstance(images, list) and images: + lines.append("\n[GENERATED_IMAGES]") + for p in images: + if p: + lines.append(f"path: {p}") + lines.append("[/GENERATED_IMAGES]") + + generated = msg.get("generated_files") + if isinstance(generated, list) and generated: + lines.append("\n[GENERATED_FILES]") + for f in generated: + if not isinstance(f, dict): + continue + + path = str(f.get("path") or "").strip() + name = str(f.get("name") or Path(path).name).strip() + + if path: + lines.append(f"name: {name}") + lines.append(f"path: {path}") + + lines.append("[/GENERATED_FILES]") + + return "\n".join(lines).strip() + +def _sanitize_plain_document_text(content: str) -> str: + text = str(content or "") + + # Markdown image: ![alt](url) + text = re.sub(r"!\[[^\]]*\]\([^)]+\)", "", text) + + # HTML img tag + text = re.sub(r"<\s*img[^>]*>", "", text, flags=re.IGNORECASE) + + # data:image base64 blokları + text = re.sub(r"data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=\s]+", "", text) + + # Basit HTML tag temizliği + text = re.sub(r"<[^>]+>", "", text) + + # Çoklu boş satırları sadeleştir + text = re.sub(r"\n{3,}", "\n\n", text) + + return text.strip() + +def _create_docx_file(output_name: str, content: str) -> Path | None: + try: + import docx + import docx.shared + + out = _generated_output_path(output_name, "docx") + doc = docx.Document() + + style = doc.styles["Normal"] + style.font.name = "Arial" + style.font.size = docx.shared.Pt(12) + + section = doc.sections[0] + section.top_margin = docx.shared.Cm(2) + section.bottom_margin = docx.shared.Cm(2) + section.left_margin = docx.shared.Cm(2) + section.right_margin = docx.shared.Cm(2) + + content = _sanitize_plain_document_text(content) + + for line in str(content or "").splitlines(): + doc.add_paragraph(line) + + doc.save(str(out)) + return out + + except Exception as e: + print("docx create error:", e, file=sys.stderr) + return None + +def _convert_docx_to_pdf(docx_path: Path) -> Path | None: + """ + DOCX dosyasını LibreOffice ile PDF'e çevirir. + """ + try: + if not docx_path.exists(): + return None + + GENERATED_FILES_DIR.mkdir(parents=True, exist_ok=True) + + subprocess.run( + [ + "libreoffice", + "--headless", + "--convert-to", + "pdf", + "--outdir", + str(GENERATED_FILES_DIR), + str(docx_path), + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + converted = GENERATED_FILES_DIR / f"{docx_path.stem}.pdf" + + if converted.exists(): + return converted + + return None + + except Exception as e: + print("docx to pdf convert error:", e, file=sys.stderr) + return None + +def _norm_filter_text(value: str) -> str: + import unicodedata + + s = str(value or "").strip().lower() + s = unicodedata.normalize("NFKD", s) + s = "".join(ch for ch in s if not unicodedata.combining(ch)) + + tr_map = str.maketrans({ + "ı": "i", + "ğ": "g", + "ü": "u", + "ş": "s", + "ö": "o", + "ç": "c", + }) + + return s.translate(tr_map) + + +def _xlsx_filter_keep_rows(source_path: str, output_name: str, spec: dict) -> Path | None: + try: + from openpyxl import load_workbook + + src = Path(source_path).expanduser() + if not src.exists() or src.suffix.lower() != ".xlsx": + return None + + out = _generated_output_path(output_name, "xlsx") + + wb = load_workbook(str(src)) + + preferred_columns = spec.get("preferred_columns") or [] + patterns = spec.get("patterns_keep_any") or [] + + preferred_columns_norm = [ + _norm_filter_text(x) for x in preferred_columns + ] + + patterns_norm = [ + _norm_filter_text(x) for x in patterns + ] + + fallback_all = bool(spec.get("fallback_search_all_columns", True)) + + for ws in wb.worksheets: + if ws.max_row < 2: + continue + + headers = [] + for cell in ws[1]: + headers.append(_norm_filter_text(cell.value)) + + target_indexes = [] + + for idx, header in enumerate(headers, start=1): + if header in preferred_columns_norm: + target_indexes.append(idx) + + if not target_indexes and fallback_all: + target_indexes = list(range(1, ws.max_column + 1)) + + rows_to_delete = [] + + for row_idx in range(2, ws.max_row + 1): + values = [] + + for col_idx in target_indexes: + values.append(ws.cell(row=row_idx, column=col_idx).value) + + joined = _norm_filter_text(" ".join(str(v or "") for v in values)) + + keep = any(pat in joined for pat in patterns_norm) + + if not keep: + rows_to_delete.append(row_idx) + + for row_idx in reversed(rows_to_delete): + ws.delete_rows(row_idx, 1) + + wb.save(str(out)) + return out + + except Exception as e: + print("xlsx filter error:", e, file=sys.stderr) + return None + +def _normalize_xlsx_rows(content): + rows = content + + # Eğer string olarak Python dict/JSON geldiyse çöz + if isinstance(rows, str): + s = rows.strip() + + try: + rows = json.loads(s) + except Exception: + try: + import ast + rows = ast.literal_eval(s) + except Exception: + return [[rows]] + + # {"sheet": "People", "data": [{...}, {...}]} + # {"sheet_name": "People", "columns": [...], "rows": [...]} + # {"sheets": [{"data": [...]}]} + if isinstance(rows, dict): + + if isinstance(rows.get("sheets"), list): + first_sheet = rows["sheets"][0] if rows["sheets"] else {} + + if isinstance(first_sheet, dict): + rows = first_sheet.get("data", []) + + elif isinstance(rows.get("data"), list): + data_rows = rows.get("data") or [] + + # data: [{Name:..., Age:...}, {...}] + if data_rows and all(isinstance(x, dict) for x in data_rows): + headers = list(data_rows[0].keys()) + rows = [headers] + [ + [item.get(h, "") for h in headers] + for item in data_rows + ] + else: + rows = data_rows + + elif isinstance(rows.get("rows"), list): + data_rows = rows.get("rows") or [] + cols = rows.get("columns") + + if isinstance(cols, list): + rows = [cols] + data_rows + else: + rows = data_rows + + else: + return [[str(rows)]] + + # [{"sheet": "...", "data": [...]}] gibi liste içinde dict geldiyse + if isinstance(rows, list) and rows and isinstance(rows[0], dict): + first = rows[0] + + if isinstance(first.get("sheets"), list): + first_sheet = first["sheets"][0] if first["sheets"] else {} + + if isinstance(first_sheet, dict): + rows = first_sheet.get("data", []) + + elif isinstance(first.get("data"), list): + data_rows = first.get("data") or [] + + if data_rows and all(isinstance(x, dict) for x in data_rows): + headers = list(data_rows[0].keys()) + rows = [headers] + [ + [item.get(h, "") for h in headers] + for item in data_rows + ] + else: + rows = data_rows + + elif isinstance(first.get("rows"), list): + data_rows = first.get("rows") or [] + cols = first.get("columns") + + if isinstance(cols, list): + rows = [cols] + data_rows + else: + rows = data_rows + + if not isinstance(rows, list): + return [[str(rows)]] + + # [[["Name","Age"],["Ali",20]]] gibi tek satır içine gömülü tablo + if ( + len(rows) == 1 + and isinstance(rows[0], list) + and rows[0] + and all(isinstance(x, list) for x in rows[0]) + ): + rows = rows[0] + + fixed_rows = [] + + for row in rows: + if isinstance(row, list): + fixed_rows.append(row) + + elif isinstance(row, dict): + headers = list(row.keys()) + + if not fixed_rows: + fixed_rows.append(headers) + + fixed_rows.append([row.get(h, "") for h in headers]) + + else: + fixed_rows.append([row]) + + return fixed_rows + +def _create_xlsx_file(output_name: str, content) -> Path | None: + try: + from openpyxl import Workbook + + out = _generated_output_path(output_name, "xlsx") + wb = Workbook() + ws = wb.active + ws.title = "Sheet1" + + rows = _normalize_xlsx_rows(content) + + for row in rows: + ws.append(row) + + wb.save(str(out)) + return out + + except Exception as e: + print("xlsx create error:", e, file=sys.stderr) + return None + +def _create_txt_like_file(output_name: str, content: str, ext: str) -> Path | None: + try: + out = _generated_output_path(output_name, ext) + out.write_text(str(content or ""), encoding="utf-8") + return out + + except Exception as e: + print(f"{ext} create error:", e, file=sys.stderr) + return None + +def _image_bytes_to_pdf_file(image_bytes: bytes, output_name: str = "edited.pdf") -> Path | None: + try: + from PIL import Image + + out = _generated_output_path(output_name, "pdf") + + img = Image.open(io.BytesIO(image_bytes)) + + if img.mode in ("RGBA", "LA", "P"): + img = img.convert("RGB") + elif img.mode != "RGB": + img = img.convert("RGB") + + img.save(str(out), "PDF", resolution=100.0) + + return out + + except Exception as e: + print("image to pdf convert error:", e, file=sys.stderr) + return None + +def _image_paths_to_pdf_file(image_paths: list[Path], output_name: str = "edited.pdf") -> Path | None: + try: + from PIL import Image + + valid = [Path(p) for p in image_paths if p and Path(p).exists()] + if not valid: + return None + + out = _generated_output_path(output_name, "pdf") + images = [] + + for p in valid: + img = Image.open(str(p)) + if img.mode in ("RGBA", "LA", "P"): + img = img.convert("RGB") + elif img.mode != "RGB": + img = img.convert("RGB") + images.append(img) + + if not images: + return None + + first = images[0] + rest = images[1:] + first.save(str(out), "PDF", save_all=True, append_images=rest, resolution=100.0) + + return out if out.exists() else None + + except Exception as e: + print("images to pdf convert error:", e, file=sys.stderr) + return None + + +def _save_result_images_to_files(result_obj: dict) -> list[Path]: + saved = [] + + if not isinstance(result_obj, dict): + return saved + + def save_raw(raw: bytes, ext: str = "png"): + out = _generated_output_path(f"edited-image.{ext}", ext) + out.write_bytes(raw) + if out.exists(): + saved.append(out) + + # URL images + for key in ("image", "url"): + val = result_obj.get(key) + if isinstance(val, str) and val.strip(): + s = val.strip() + if s.startswith("data:image/") and "," in s: + try: + head, b64 = s.split(",", 1) + ext = head.split("/")[1].split(";")[0] or "png" + save_raw(base64.b64decode(b64), ext) + except Exception: + pass + elif s.startswith(("http://", "https://")): + try: + r = requests.get(s, timeout=120) + if r.status_code == 200: + save_raw(r.content, "png") + except Exception: + pass + + imgs = result_obj.get("images") + if isinstance(imgs, list): + for item in imgs: + if isinstance(item, str): + s = item.strip() + if s.startswith("data:image/") and "," in s: + try: + head, b64 = s.split(",", 1) + ext = head.split("/")[1].split(";")[0] or "png" + save_raw(base64.b64decode(b64), ext) + except Exception: + pass + elif s.startswith(("http://", "https://")): + try: + r = requests.get(s, timeout=120) + if r.status_code == 200: + save_raw(r.content, "png") + except Exception: + pass + + elif isinstance(item, dict): + u = item.get("url") or item.get("image_url") + if isinstance(u, dict): + u = u.get("url") + if isinstance(u, str) and u.strip(): + try: + r = requests.get(u.strip(), timeout=120) + if r.status_code == 200: + save_raw(r.content, "png") + except Exception: + pass + + # base64 images + for key in ("image_base64",): + b64 = result_obj.get(key) + if isinstance(b64, str) and b64.strip(): + try: + save_raw(base64.b64decode(b64.split(",", 1)[-1]), "png") + except Exception: + pass + + many_b64 = result_obj.get("images_base64") + if isinstance(many_b64, list): + for b64 in many_b64: + if isinstance(b64, str) and b64.strip(): + try: + save_raw(base64.b64decode(b64.split(",", 1)[-1]), "png") + except Exception: + pass + + return saved + + +def _overlay_images_on_pdf_blocks(source_pdf: Path, layout: dict, replacement_images: list[Path], output_name: str = "edited-pdf-image.pdf") -> Path | None: + try: + import fitz + + source_pdf = Path(source_pdf) + if not source_pdf.exists(): + return None + + doc = fitz.open(str(source_pdf)) + out = _generated_output_path(output_name, "pdf") + + image_blocks = [] + for page in layout.get("pages", []): + page_no = int(page.get("page") or 1) + for block in page.get("blocks", []): + if block.get("type") == "image": + image_blocks.append((page_no, block)) + + if not image_blocks or not replacement_images: + doc.close() + return None + + for idx, img_path in enumerate(replacement_images): + if idx >= len(image_blocks): + break + + page_no, block = image_blocks[idx] + bbox = block.get("bbox") + if not bbox or len(bbox) != 4: + continue + + page = doc[page_no - 1] + page.insert_image( + fitz.Rect(*bbox), + filename=str(img_path), + overlay=True + ) + + doc.save(str(out)) + doc.close() + + return out if out.exists() else None + + except Exception as e: + print("overlay pdf image error:", e, file=sys.stderr) + return None + +def _save_ai_returned_file(name: str, data: bytes) -> Path | None: + try: + ext = Path(name or "").suffix.lower().lstrip(".") or "bin" + out = _generated_output_path(name, ext) + out.write_bytes(data) + return out + except Exception as e: + print("ai returned file save error:", e, file=sys.stderr) + return None + + +def _apply_ai_returned_files_from_result(result_obj: dict) -> list[dict]: + generated = [] + + if not isinstance(result_obj, dict): + return generated + + # 1) Base64 dosyalar + files_b64 = result_obj.get("generated_files_base64") + if isinstance(files_b64, list): + for item in files_b64: + if not isinstance(item, dict): + continue + + name = str(item.get("name") or item.get("output_name") or "generated.pdf").strip() + b64 = str(item.get("base64") or item.get("data") or "").strip() + + if b64.startswith("data:") and "," in b64: + b64 = b64.split(",", 1)[1].strip() + + if not b64: + continue + + try: + raw = base64.b64decode(b64) + except Exception: + continue + + out = _save_ai_returned_file(name, raw) + + if out and out.exists(): + generated.append({ + "path": str(out.resolve()), + "name": out.name + }) + + # 2) URL dosyalar + files_url = result_obj.get("generated_files_url") + if isinstance(files_url, list): + for item in files_url: + if not isinstance(item, dict): + continue + + name = str(item.get("name") or item.get("output_name") or "generated.pdf").strip() + url = str(item.get("url") or "").strip() + + if not url.startswith(("http://", "https://")): + continue + + try: + r = requests.get(url, timeout=120) + if r.status_code != 200: + continue + + out = _save_ai_returned_file(name, r.content) + + if out and out.exists(): + generated.append({ + "path": str(out.resolve()), + "name": out.name + }) + + except Exception as e: + print("ai returned file url download error:", e, file=sys.stderr) + + return generated + +def _apply_file_create_from_reply(reply_text: str) -> list[dict]: + block = _extract_named_block(reply_text, "file_create") + if not block: + return [] + + try: + ops = json.loads(block) + except Exception as e: + print("file_create json parse error:", e, file=sys.stderr) + return [] + + if isinstance(ops, dict): + ops = [ops] + + if not isinstance(ops, list): + return [] + + generated = [] + + for op in ops: + if not isinstance(op, dict): + continue + + fmt = str(op.get("format") or "").strip().lower().lstrip(".") + output_name = str(op.get("output_name") or "").strip() + content = op.get("content", "") + + out = None + + if fmt == "docx": + out = _create_docx_file(output_name, str(content or "")) + + if out and out.exists(): + generated.append({ + "path": str(out.resolve()), + "name": out.name + }) + + pdf_out = _convert_docx_to_pdf(out) + if pdf_out and pdf_out.exists(): + generated.append({ + "path": str(pdf_out.resolve()), + "name": pdf_out.name + }) + + continue + + elif fmt == "xlsx": + source_path = str(op.get("source_path") or "").strip() + + if isinstance(content, list) and content and isinstance(content[0], dict): + first_spec = content[0] + action = str(first_spec.get("action") or "").strip() + + if action == "filter_keep_rows": + out = _xlsx_filter_keep_rows(source_path, output_name, first_spec) + else: + out = _create_xlsx_file(output_name, content) + else: + out = _create_xlsx_file(output_name, content) + + elif fmt in ("txt", "md"): + out = _create_txt_like_file(output_name, str(content or ""), fmt) + + if out and out.exists(): + generated.append({ + "path": str(out.resolve()), + "name": out.name + }) + + return generated + +def _build_blocks_and_cache_info(msg: dict, cache_images_dir: Path | None, pdf_mode: str = "auto"): blocks = [] cached_image = None @@ -260,25 +1596,204 @@ def _build_blocks_and_cache_info(msg: dict, cache_images_dir: Path | None): name = (f.get("name") or p.name or "").strip() mime = _mime_for(p) + file_text = None + file_type = "unsupported" + handled_file = False + if _is_text_file(p): file_text = _safe_read_text(p) + file_type = "text" + + elif p.suffix.lower() == ".pdf": + + # IMAGE MODE override — HER ZAMAN image gönder + if pdf_mode == "image": + blocks.append({ + "type": "text", + "text": ( + "\n[FILE]\n" + f"name: {name}\n" + f"mime: {mime}\n" + f"editable: {'true' if editable else 'false'}\n" + "type: pdf_visual_pages\n" + "[/FILE]\n" + ) + }) + + page_images = _pdf_to_page_images(p, max_pages=12, zoom=1.5) + + if not page_images: + raise RuntimeError( + get_ui_text(load_config(), "o_PDF_Convert_Failed") + ) + + for img_path in page_images: + blocks.append({ + "type": "image_url", + "image_url": { + "url": _data_url_for_image(img_path) + } + }) + + handled_file = True + continue + + analysis = _analyze_pdf_kind(p) + pdf_kind = analysis.get("kind") + pdf_text = analysis.get("text") or "" + + # 1) Sadece yazı PDF + if pdf_kind == "text_only": + file_text = pdf_text + file_type = "pdf_text_only" + + if not str(file_text or "").strip(): + blocks.append({ + "type": "text", + "text": ( + get_ui_text(load_config(), "o_PDF_Text_Extract_Failed") + ) + }) + handled_file = True + continue + + # 2) Hem yazı hem resim PDF + elif pdf_kind == "mixed": + layout = _extract_pdf_mixed_layout(p) + + if pdf_mode == "text": + text_blocks = [] + + for page in layout.get("pages", []): + for block in page.get("blocks", []): + if block.get("type") == "text": + text_blocks.append({ + "id": block.get("id"), + "page": page.get("page"), + "text": block.get("text"), + }) + + blocks.append({ + "type": "text", + "text": ( + "\n[FILE]\n" + f"name: {name}\n" + f"source_path_for_app_only: {str(p)}\n" + f"mime: {mime}\n" + f"editable: {'true' if editable else 'false'}\n" + "type: pdf_mixed_text_only\n" + "note: Images are preserved by the app. Do not regenerate or describe images.\n" + "[CONTENT]\n" + f"{json.dumps(text_blocks, ensure_ascii=False)}\n" + "[/CONTENT]\n" + "[/FILE]\n" + ) + }) + handled_file = True + + elif pdf_mode == "image": + image_blocks = [] + + for page in layout.get("pages", []): + for block in page.get("blocks", []): + if block.get("type") == "image": + image_blocks.append(block) + img_path = Path(str(block.get("path") or "")) + + if img_path.exists(): + blocks.append({ + "type": "image_url", + "image_url": { + "url": _data_url_for_image(img_path) + } + }) + + if not image_blocks: + blocks.append({ + "type": "text", + "text": get_ui_text(load_config(), "o_PDF_No_Images_Found") + }) + + handled_file = True + + else: + blocks.append({ + "type": "text", + "text": ( + "\n[FILE]\n" + f"name: {name}\n" + f"path: {str(p)}\n" + f"mime: {mime}\n" + f"editable: {'true' if editable else 'false'}\n" + "type: pdf_mixed_separated_layout\n" + "[CONTENT]\n" + f"{json.dumps(layout, ensure_ascii=False)}\n" + "[/CONTENT]\n" + "[/FILE]\n" + ) + }) + handled_file = True + + # 3) Yazı yok / scan / image PDF + else: + blocks.append({ + "type": "text", + "text": ( + "\n[FILE]\n" + f"name: {name}\n" + f"path: {str(p)}\n" + f"mime: {mime}\n" + f"editable: {'true' if editable else 'false'}\n" + "type: pdf_visual_pages\n" + "note: PDF text could not be extracted. Pages are sent as images.\n" + "[/FILE]\n" + ) + }) + + + page_images = _pdf_to_page_images(p, max_pages=12, zoom=1.5) + handled_file = True + + if not page_images: + raise RuntimeError( + get_ui_text(load_config(), "o_PDF_Convert_Failed") + ) + + for img_path in page_images: + blocks.append({ + "type": "image_url", + "image_url": { + "url": _data_url_for_image(img_path) + } + }) + + + elif p.suffix.lower() == ".docx": + file_text = _safe_read_docx(p) + file_type = "docx_text" + + elif p.suffix.lower() == ".xlsx": + file_text = _safe_read_xlsx(p) + file_type = "xlsx_text" + + if file_text is not None: blocks.append({ "type": "text", "text": ( "\n[FILE]\n" f"name: {name}\n" - f"path: {str(p)}\n" + f"source_path_for_app_only: {str(p)}\n" f"mime: {mime}\n" f"editable: {'true' if editable else 'false'}\n" - "type: text\n" + f"type: {file_type}\n" + "IMPORTANT: The file content is already extracted below. Do not access the path.\n" "[CONTENT]\n" f"{file_text}\n" "[/CONTENT]\n" "[/FILE]\n" ) }) - else: - # Excel/PDF/DOCX vb. şu an içerik aktarmıyoruz (metadata gönderiyoruz) + elif not handled_file: blocks.append({ "type": "text", "text": ( @@ -287,8 +1802,8 @@ def _build_blocks_and_cache_info(msg: dict, cache_images_dir: Path | None): f"path: {str(p)}\n" f"mime: {mime}\n" f"editable: {'true' if editable else 'false'}\n" - "type: binary_or_unsupported_preview\n" - "note: Bu dosya türü şu an içerik olarak gönderilmiyor.\n" + "type: unsupported\n" + "note: Bu dosya türü şu an metin olarak okunamıyor.\n" "[/FILE]\n" ) }) @@ -461,6 +1976,130 @@ def _extract_images_from_message(msg: dict) -> dict: return out +def _extract_images_from_anywhere(obj) -> dict: + """ + OpenRouter / provider response içinde image url veya base64 farklı yerlerde dönebilir. + Bu fallback tüm response JSON içinde güvenli şekilde image benzeri alanları arar. + """ + out = { + "type": "text", + "content": "", + } + + image_urls = [] + image_b64 = [] + + image_key_names = { + "image", + "url", + "image_url", + "output_image", + "b64_json", + "image_base64", + "base64", + "data", + } + + def add_url(s: str): + s = str(s or "").strip() + if not s: + return + + if s.startswith("data:image/"): + image_urls.append(s) + return + + low = s.lower() + if low.startswith("http://") or low.startswith("https://"): + if any(x in low for x in [".png", ".jpg", ".jpeg", ".webp", ".gif"]) or "image" in low: + image_urls.append(s) + + def add_b64(s: str): + s = str(s or "").strip() + if not s: + return + + if s.startswith("data:image/"): + image_urls.append(s) + return + + # çok kısa stringleri base64 sanma + if len(s) < 500: + return + + # basit base64 karakter kontrolü + if re.fullmatch(r"[A-Za-z0-9+/=\s]+", s): + image_b64.append(s) + + def walk(x, parent_key=""): + if isinstance(x, dict): + for k, v in x.items(): + key = str(k or "").strip().lower() + + if isinstance(v, str): + if key in image_key_names or "image" in key or "b64" in key: + if key in ("b64_json", "image_base64", "base64") or "b64" in key: + add_b64(v) + else: + add_url(v) + add_b64(v) + else: + # data:image/... bazen key alakasız olabilir + if v.strip().startswith("data:image/"): + add_url(v) + + else: + walk(v, key) + + elif isinstance(x, list): + for item in x: + walk(item, parent_key) + + elif isinstance(x, str): + if x.strip().startswith("data:image/"): + add_url(x) + + walk(obj) + + def uniq(seq): + seen = set() + result = [] + for item in seq: + key = item[:200] + str(len(item)) + if key in seen: + continue + seen.add(key) + result.append(item) + return result + + image_urls = uniq(image_urls) + image_b64 = uniq(image_b64) + + if image_urls: + out["type"] = "image" + out["images"] = image_urls + + if image_b64: + out["type"] = "image" + out["images_base64"] = image_b64 + + return out + +def _split_local_model(model_name: str) -> tuple[str, str]: + raw = str(model_name or "").strip() + if not raw or "/" not in raw: + cfg = load_config() + _die(get_ui_text(cfg, "o_Invalid_Local_Model_Format", model=raw), 1) + + provider, actual_model = raw.split("/", 1) + provider = provider.strip().lower() + actual_model = actual_model.strip() + + if not provider or not actual_model: + cfg = load_config() + _die(get_ui_text(cfg, "o_Invalid_Local_Model_Format", model=raw), 1) + + return provider, actual_model def _is_image_generation_model(model_name: str) -> bool: m = str(model_name or "").strip().lower() @@ -473,40 +2112,269 @@ def _is_image_generation_model(model_name: str) -> bool: "stable-diffusion" ]) -def _pick_model(cfg: dict, chat_file: Path, cli_model: str | None) -> str: - """ - Öncelik: - 1) CLI --model verilmişse onu kullan (alternatif) - 2) chat_models[chat_file.name] - 3) ai_models[0] (en üst = en son kullanılan) - Eğer chat'in modeli config'te yoksa/silinmişse otomatik ai_models[0]'a düzeltir. - """ - models = cfg.get("ai_models", []) - if not isinstance(models, list): - models = [] - models = [str(m).strip() for m in models if str(m).strip()] +def _normalize_model_entry(item) -> dict | None: + if isinstance(item, dict): + mid = str(item.get("id") or "").strip() + if not mid: + return None + return { + "id": mid, + "local": bool(item.get("local", False)) + } + if isinstance(item, str): + mid = item.strip() + if not mid: + return None + return { + "id": mid, + "local": False + } + + return None + + +def _get_models_list(cfg: dict) -> list[dict]: + raw = cfg.get("ai_models", []) + if not isinstance(raw, list): + raw = [] + + out = [] + seen = set() + + for item in raw: + norm = _normalize_model_entry(item) + if not norm: + continue + + mid = norm["id"] + if mid in seen: + continue + + seen.add(mid) + out.append(norm) + + return out + + +def _get_chat_model_entry(cfg: dict, chat_file: Path) -> dict: + models = _get_models_list(cfg) if not models: - _die("Config içinde ai_models boş. En az 1 model olmalı.", 1) + _die("ai_models in config is empty. At least one model must be defined.", 1) - if cli_model: - return str(cli_model).strip() + model_ids = {m["id"] for m in models} chat_models = cfg.get("chat_models", {}) if not isinstance(chat_models, dict): chat_models = {} chat_key = chat_file.name - chosen = str(chat_models.get(chat_key) or "").strip() + selected = chat_models.get(chat_key) - if (not chosen) or (chosen not in models): - chosen = models[0] - chat_models[chat_key] = chosen + chosen_id = "" + chosen_local = False + + if isinstance(selected, dict): + chosen_id = str(selected.get("id") or "").strip() + chosen_local = bool(selected.get("local", False)) + elif isinstance(selected, str): + chosen_id = selected.strip() + chosen_local = False + + if not chosen_id or chosen_id not in model_ids: + fallback = models[0] + chosen_id = fallback["id"] + chosen_local = bool(fallback.get("local", False)) + + chat_models[chat_key] = { + "id": chosen_id, + "local": chosen_local + } + cfg["chat_models"] = chat_models + _save_config(cfg) + return { + "id": chosen_id, + "local": chosen_local + } + + # ai_models içindeki gerçek local değerini koru + for m in models: + if m["id"] == chosen_id: + chosen_local = bool(m.get("local", False)) + break + + fixed_entry = { + "id": chosen_id, + "local": chosen_local + } + + if selected != fixed_entry: + chat_models[chat_key] = fixed_entry cfg["chat_models"] = chat_models _save_config(cfg) - return chosen + return fixed_entry +def _pick_model(cfg: dict, chat_file: Path, cli_model: str | None) -> dict: + if cli_model: + return { + "id": str(cli_model).strip(), + "local": False + } + + return _get_chat_model_entry(cfg, chat_file) + +def _coerce_number(val: str): + s = str(val or "").strip() + if not s: + return None + try: + if "." in s: + return float(s) + return int(s) + except Exception: + return None + + +def _build_local_options(provider_cfg: dict) -> dict: + opts = {} + + num = _coerce_number(provider_cfg.get("temperature", "")) + if num is not None: + opts["temperature"] = num + + num = _coerce_number(provider_cfg.get("top_p", "")) + if num is not None: + opts["top_p"] = num + + num = _coerce_number(provider_cfg.get("top_k", "")) + if num is not None: + opts["top_k"] = num + + num = _coerce_number(provider_cfg.get("repeat_penalty", "")) + if num is not None: + opts["repeat_penalty"] = num + + num = _coerce_number(provider_cfg.get("num_ctx", "")) + if num is not None: + opts["num_ctx"] = num + + num = _coerce_number(provider_cfg.get("num_predict", "")) + if num is not None: + opts["num_predict"] = num + + keep_alive = str(provider_cfg.get("keep_alive") or "").strip() + if keep_alive: + opts["keep_alive"] = keep_alive + + return opts + + +def _flatten_messages_for_local(final_messages: list[dict]) -> str: + parts = [] + + for msg in final_messages: + if not isinstance(msg, dict): + continue + + role = str(msg.get("role") or "").strip().upper() + content = msg.get("content") + + if isinstance(content, str): + txt = content.strip() + if txt: + parts.append(f"[{role}]\n{txt}") + continue + + if isinstance(content, list): + subparts = [] + for item in content: + if not isinstance(item, dict): + continue + + itype = str(item.get("type") or "").strip().lower() + + if itype == "text": + txt = str(item.get("text") or "").strip() + if txt: + subparts.append(txt) + + elif itype == "image_url": + iu = item.get("image_url") + if isinstance(iu, dict): + u = str(iu.get("url") or "").strip() + else: + u = str(iu or "").strip() + + if u: + subparts.append(f"[IMAGE]\n{u}") + + joined = "\n".join(x for x in subparts if x.strip()).strip() + if joined: + parts.append(f"[{role}]\n{joined}") + + return "\n\n".join(parts).strip() + + +def _call_ollama(provider_cfg: dict, actual_model: str, final_messages: list[dict]) -> str: + base_url = str(provider_cfg.get("base_url") or "").strip() or "http://127.0.0.1:11434" + url = base_url.rstrip("/") + "/api/generate" + + prompt = _flatten_messages_for_local(final_messages) + + payload = { + "model": actual_model, + "prompt": prompt, + "stream": False, + } + + system_prompt = str(provider_cfg.get("system_prompt") or "").strip() + if system_prompt: + payload["system"] = system_prompt + + options = _build_local_options(provider_cfg) + if options: + payload["options"] = options + + r = requests.post( + url, + json=payload, + timeout=300 + ) + + if r.status_code != 200: + cfg = load_config() + _die(get_ui_text(cfg, "o_Ollama_API_Error", status_code=r.status_code, details=r.text[:1000]), 1) + + try: + data = r.json() + except Exception as e: + cfg = load_config() + _die(get_ui_text(cfg, "o_Ollama_JSON_Parse_Error", error=e), 1) + + text = str(data.get("response") or "").strip() + if not text: + cfg = load_config() + _die(get_ui_text(cfg, "o_Ollama_Empty_Response"), 1) + + result_obj = { + "type": "text", + "content": fix_mojibake(text) + } + + prompt_eval = data.get("prompt_eval_count") + eval_count = data.get("eval_count") + + if prompt_eval is not None or eval_count is not None: + p = int(prompt_eval or 0) + c = int(eval_count or 0) + result_obj["usage"] = { + "prompt_tokens": p, + "completion_tokens": c, + "total_tokens": p + c + } + + return json.dumps(result_obj, ensure_ascii=False) def main(): try: @@ -525,11 +2393,17 @@ def main(): parser.add_argument("chat_file", nargs="?") parser.add_argument("selected_indexes", nargs="?") parser.add_argument("--model", dest="model", default=None) + parser.add_argument("--context-mode", dest="context_mode", default="rag") + parser.add_argument("--approved-web-search-query", dest="approved_web_search_query", default=None) if len(sys.argv) < 2: sys.exit(1) args, _unknown = parser.parse_known_args() + + context_mode = str(args.context_mode or "rag").strip().lower() + if context_mode not in ("rag", "direct"): + context_mode = "rag" if not args.chat_file: sys.exit(1) @@ -545,22 +2419,62 @@ def main(): except Exception: selected_indexes = [] - # Config + API key + # Config cfg = _load_config() response_style = (cfg.get("response_style") or "").strip() - API_KEY = (cfg.get("open_router_key") or "").strip() - if not API_KEY: - _die("OpenRouter API Key bulunamadı.", 1) # model seçimi (chat’e göre) - model_name = _pick_model(cfg, chat_file, args.model) + model_entry = _pick_model(cfg, chat_file, args.model) + model_name = str(model_entry["id"]).strip() + is_local_model = bool(model_entry.get("local", False)) + + local_providers = cfg.get("local_providers", {}) + if not isinstance(local_providers, dict): + local_providers = {} + + API_KEY = "" + if not is_local_model: + API_KEY = (cfg.get("open_router_key") or "").strip() + if not API_KEY: + _die(get_ui_text(cfg, "o_OpenRouter_Key_Missing"), 1) + + # read rag settings + rag_settings = cfg.get("rag_settings", {}) + if not isinstance(rag_settings, dict): + rag_settings = {} + + def rag_bool(key, default): + return bool(rag_settings.get(key, default)) + + def rag_int(key, default, min_v=0, max_v=100_000): + try: + val = int(rag_settings.get(key, default)) + except Exception: + val = default + return max(min_v, min(max_v, val)) + + ask_for_web_search = bool(cfg.get("ask_for_web_search", True)) + approved_web_search_query = str(args.approved_web_search_query or "").strip() + + recent_message_count = rag_int("recent_message_count", 10, 0, 50) + retrieved_chunk_count = rag_int("retrieved_chunk_count", 5, 0, 50) + summary_update_every = rag_int("summary_update_every", 20, 1, 200) + memory_chunk_max_chars = rag_int("memory_chunk_max_chars", 1200, 100, 100_000) + summary_max_chars = rag_int("summary_max_chars", 6000, 100, 100_000) + code_context_max_chars = rag_int("code_context_max_chars", 8000, 100, 100_000) + + use_summary = rag_bool("use_summary", True) + use_recent_messages = rag_bool("use_recent_messages", True) + use_retrieval = rag_bool("use_retrieval", True) + use_code_context = rag_bool("use_code_context", True) + include_recent_attachments = rag_bool("include_recent_attachments", False) # Read chat - messages = [] try: - messages = json.loads(chat_file.read_text(encoding="utf-8")) or [] + chat_data = load_chat_data(chat_file) + messages = get_chat_messages(chat_data) except Exception as e: - _die(f"Chat okunamadı: {e}", 1) + _die(get_ui_text(cfg, "o_Chat_Read_Error", error=e), 1) if not messages: sys.exit(0) @@ -580,20 +2494,158 @@ def main(): break if last_user is None: - _die("Chat içinde user mesajı yok.", 1) + _die(get_ui_text(cfg, "o_No_User_Message"), 1) last = last_user final_messages = [] editable_map = {} # path(str) -> bool - system_prompt = f"{GLOBAL_SYSTEM_PROMPT}" - + # Son user mesajını memory sistemine ekle + last_text = str(last.get("content") or "").strip() + + files_last = last.get("files") or [] + + is_pdf_text_only_request = False + is_pdf_image_only_request = False + is_pdf_mixed_request = False + is_pdf_mixed_image_only_request = False + + mixed_pdf_layout = None + mixed_pdf_source = None + + if isinstance(files_last, list): + for ff in files_last: + if not isinstance(ff, dict): + continue + + p = Path(str(ff.get("path") or "")) + editable = bool(ff.get("edit")) + + if editable and p.suffix.lower() == ".pdf": + analysis = _analyze_pdf_kind(p) + kind = analysis.get("kind") + + if kind == "text_only": + is_pdf_text_only_request = True + + elif kind == "mixed": + mixed_pdf_source = p + mixed_pdf_layout = _extract_pdf_mixed_layout(p) + + if mixed_pdf_layout and mixed_pdf_layout.get("pages"): + is_pdf_mixed_request = True + else: + # Layout çıkarılamıyorsa güvenli fallback: + # metin varsa text-only gibi davran. + fallback_text = analysis.get("text") or _safe_read_pdf(p) + + if len((fallback_text or "").strip()) >= 80: + is_pdf_text_only_request = True + else: + is_pdf_image_only_request = True + + else: + is_pdf_image_only_request = True + + break + + if context_mode == "rag": + # Son user mesajını memory sistemine ekle + if last_text: + add_memory_chunk( + chat_data, + last_text[:memory_chunk_max_chars], + source="chat:user" + ) + + # Eğer son mesajda kod dosyası varsa code_context oluştur + if use_code_context: + update_code_context_from_message(chat_data, last) + + selected_prompt_blocks = cfg.get("prompt_chooser_blocks", ["copyable"]) + + selected_prompt_blocks_set = { + str(x or "").strip() + for x in selected_prompt_blocks + if str(x or "").strip() + } + + pdf_mode_text = "pdf_text" in selected_prompt_blocks_set + pdf_mode_image = "pdf_image" in selected_prompt_blocks_set + pdf_mode_text_image = "pdf_text_image" in selected_prompt_blocks_set + + if is_pdf_mixed_request: + wants_image = _user_wants_image_edit(last_text) + wants_text = _user_wants_text_edit(last_text) + + # Kullanıcı text istiyor ama resim aynı kalsın diyorsa: + # AI'ye image edit yaptırma. + if wants_text and not wants_image: + pdf_mode_text = True + pdf_mode_image = False + pdf_mode_text_image = False + + is_pdf_mixed_image_only_request = wants_image and not wants_text + + has_editable_pdf_request = ( + is_pdf_text_only_request + or is_pdf_image_only_request + or is_pdf_mixed_request + ) + + if has_editable_pdf_request and not (pdf_mode_text or pdf_mode_image or pdf_mode_text_image): + msg = get_ui_text(cfg, "o_PDF_Mode_Required") + + result_obj = { + "type": "text", + "content": msg + } + sys.stdout.write(json.dumps(result_obj, ensure_ascii=False)) + sys.stdout.flush() + sys.exit(0) + + system_prompt = build_global_system_prompt(selected_prompt_blocks, cfg) + + if is_pdf_text_only_request and pdf_mode_text: + system_prompt += "\n\n" + get_prompt_text(cfg, PDF_RULE_KEYS["pdf_text_only_to_docx"]) + + elif is_pdf_image_only_request and pdf_mode_image: + system_prompt += "\n\n" + get_prompt_text(cfg, PDF_RULE_KEYS["pdf_image"]) + + elif is_pdf_mixed_request: + if pdf_mode_text: + system_prompt += "\n\n" + get_prompt_text(cfg, PDF_RULE_KEYS["pdf_mixed_text_only"]) + + elif pdf_mode_image: + system_prompt += "\n\n" + get_prompt_text(cfg, PDF_RULE_KEYS["pdf_image"]) + + elif pdf_mode_text_image: + system_prompt += "\n\n" + get_prompt_text(cfg, PDF_RULE_KEYS["pdf_mixed_separate"]) final_messages.append({ "role": "system", "content": system_prompt }) + if context_mode == "rag" and (use_summary or use_retrieval or use_code_context): + memory_context = build_memory_context(chat_data, last_text) + + if not use_summary: + # Basit filtre: summary başlığı varsa kaldırmak için memory.py tarafında daha temiz yapılabilir. + pass + + if memory_context: + memory_context = memory_context[:summary_max_chars + code_context_max_chars + 4000] + + final_messages.append({ + "role": "system", + "content": ( + "Aşağıdaki bilgiler konuşma devamlılığı için verilmiştir. " + "Bunlara doğrudan cevap verme, sadece son kullanıcı mesajını anlamak için kullan.\n\n" + + memory_context + ) + }) + cfg = load_config() force_lang = bool(cfg.get("force_ui_language", False)) @@ -613,6 +2665,74 @@ def main(): "content": f"Kullanıcı şu konuşma tarzını tercih ediyor: {response_style}" }) + # ---------------- RECENT MESSAGES MEMORY ---------------- + # Burada sadece önceki son 10 konuşma mesajını ekliyoruz. + + if context_mode == "rag" and use_recent_messages and recent_message_count > 0: + recent_messages = [] + + lookback_count = recent_message_count + 2 + + for m in messages[-lookback_count:]: + if not isinstance(m, dict): + continue + + if m.get("streaming"): + continue + + # Son user mesajını tekrar ekleme + if m is last: + continue + + role_raw = m.get("role") + if role_raw not in ("user", "bot", "assistant"): + continue + + recent_messages.append(m) + + recent_messages = recent_messages[-recent_message_count:] + + if recent_messages: + final_messages.append({ + "role": "system", + "content": ( + "Aşağıdaki mesajlar konuşmanın son kısmıdır. " + "Bunlara tek tek cevap verme; sadece en son kullanıcı mesajını anlamak için kullan." + ) + }) + + for m in recent_messages: + role = _role_map(m.get("role")) + + has_any_attach = False + + if m.get("image") and Path(str(m["image"])).exists(): + has_any_attach = True + + imgs = m.get("images") + if isinstance(imgs, list) and any(Path(str(p)).exists() for p in imgs): + has_any_attach = True + + files = m.get("files") + if isinstance(files, list) and any( + Path(str(f.get("path") or "")).exists() + for f in files + if isinstance(f, dict) + ): + has_any_attach = True + + if has_any_attach and include_recent_attachments: + blocks, _ = _build_blocks_and_cache_info(m, cache_images_dir=None) + final_messages.append({ + "role": role, + "content": blocks + }) + else: + final_messages.append({ + "role": role, + "content": _message_content_with_generated_files(m) + }) + # Cache setup REFS_CACHE_DIR.mkdir(parents=True, exist_ok=True) @@ -696,7 +2816,6 @@ def main(): # Last message (answer) - TEXT + (image / images) # last message editable_map doldur - files_last = last.get("files") or [] if isinstance(files_last, list): for ff in files_last: if not isinstance(ff, dict): @@ -729,25 +2848,197 @@ def main(): has_any_attach = True if has_any_attach: - blocks, _ = _build_blocks_and_cache_info(last, cache_images_dir=None) + pdf_mode_for_blocks = "auto" + + if pdf_mode_text: + pdf_mode_for_blocks = "text" + elif pdf_mode_image: + pdf_mode_for_blocks = "image" + elif pdf_mode_text_image: + pdf_mode_for_blocks = "auto" + + blocks, _ = _build_blocks_and_cache_info( + last, + cache_images_dir=None, + pdf_mode=pdf_mode_for_blocks + ) final_messages.append({"role": role, "content": blocks}) else: final_messages.append({"role": role, "content": last.get("content", "")}) # Payload - is_image_model = _is_image_generation_model(model_name) - payload = { - "model": model_name, - "messages": final_messages, - "max_tokens": 5120, - "stream": False if is_image_model else True - } - - # Request (STREAMING) reply = "" usage_obj = None try: + if is_local_model: + provider, actual_model = _split_local_model(model_name) + provider_cfg = local_providers.get(provider) + + if not isinstance(provider_cfg, dict): + _die(get_ui_text(cfg, "o_Local_Provider_Not_Found", provider=provider), 1) + + if not bool(provider_cfg.get("enabled", False)): + _die(get_ui_text(cfg, "o_Local_Provider_Disabled", provider=provider), 1) + + run_startup = str(provider_cfg.get("run_startup") or "").strip() + + if run_startup: + try: + subprocess.Popen( + run_startup, + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + except Exception: + pass + else: + # chat'e mesaj döndür (stdout) + result_obj = { + "type": "text", + "content": get_ui_text(cfg, "o_Start_Local_Server_From_App", provider=provider) + } + sys.stdout.write(json.dumps(result_obj, ensure_ascii=False)) + sys.stdout.flush() + sys.exit(1) + + if provider == "ollama": + reply = _call_ollama(provider_cfg, actual_model, final_messages) + parsed_reply = json.loads(reply) + assistant_text = str(parsed_reply.get("content") or "").strip() + + web_query = _extract_web_search_call(assistant_text) + + if web_query and ("web_search" in selected_prompt_blocks_set): + web_results = _tavily_search(cfg, web_query, max_results=5) + + final_messages.append({ + "role": "assistant", + "content": assistant_text + }) + + final_messages.append({ + "role": "system", + "content": ( + "Web search results are below. " + "Answer the user's last question using only these results.\n\n" + + web_results + ) + }) + + reply = _call_ollama(provider_cfg, actual_model, final_messages) + + sys.stdout.write(reply) + sys.stdout.flush() + + try: + parsed_reply = json.loads(reply) + assistant_text = str(parsed_reply.get("content") or "").strip() if isinstance(parsed_reply, dict) else "" + + if context_mode == "rag": + if assistant_text: + add_memory_chunk( + chat_data, + assistant_text[:memory_chunk_max_chars], + source="chat:assistant" + ) + + if use_summary and should_update_summary(chat_data, every=summary_update_every): + chat_data["summary"] = make_simple_summary(chat_data)[:summary_max_chars] + + save_chat_data(chat_file, chat_data) + + except Exception: + pass + + sys.exit(0) + + else: + _die(get_ui_text(cfg, "o_Unsupported_Local_Provider", provider=provider), 1) + + # Remote / OpenRouter + is_image_model = _is_image_generation_model(model_name) + + if (is_pdf_text_only_request or is_pdf_mixed_request) and is_image_model: + result_obj = { + "type": "text", + "content": get_ui_text(cfg, "o_PDF_Has_Text_Select_Text_Model") + } + sys.stdout.write(json.dumps(result_obj, ensure_ascii=False)) + sys.stdout.flush() + sys.exit(0) + + # Text tabanlı PDF düzenleme isteklerinde image model kullanma. + # Çünkü image model metni düzenlemek yerine görsel üretmeye çalışır. + try: + for ff in files_last: + if not isinstance(ff, dict): + continue + + p = Path(str(ff.get("path") or "")) + if p.suffix.lower() == ".pdf" and bool(ff.get("edit")): + has_editable_pdf = True + break + except Exception: + pass + + selected_prompt_blocks = cfg.get("prompt_chooser_blocks", ["copyable"]) + if not isinstance(selected_prompt_blocks, list): + selected_prompt_blocks = ["copyable"] + + selected_prompt_blocks = { + str(x or "").strip() + for x in selected_prompt_blocks + } + + has_editable_generated_doc = False + try: + for ff in files_last: + if not isinstance(ff, dict): + continue + + p = Path(str(ff.get("path") or "")) + if p.suffix.lower() in (".docx", ".xlsx", ".txt", ".md", ".pdf") and bool(ff.get("edit")): + has_editable_generated_doc = True + break + except Exception: + pass + + wants_file_create = ( + has_editable_generated_doc + and ("file_create" in selected_prompt_blocks) + ) + + has_input_images = bool( + last.get("image") + or last.get("images") + or any( + isinstance(f, dict) and str(f.get("path") or "").lower().endswith((".png", ".jpg", ".jpeg", ".webp")) + for f in files_last + ) + ) + + should_stream = ( + (not has_input_images) + and (not wants_file_create) + and (not is_pdf_text_only_request) + and (not is_pdf_image_only_request) + and (not is_pdf_mixed_request) + ) + + web_search_enabled = "web_search" in selected_prompt_blocks + + if web_search_enabled: + should_stream = False + + payload = { + "model": model_name, + "messages": final_messages, + "max_tokens": 5120, + "stream": should_stream + } + response = requests.post( OPENROUTER_URL, headers={ @@ -758,14 +3049,14 @@ def main(): }, json=payload, timeout=120, - stream=not is_image_model + stream=should_stream ) if response.status_code != 200: - sys.stderr.write(f"OpenRouter API ERROR ({response.status_code}): {response.text}\n") + sys.stderr.write(get_ui_text(cfg, "o_OpenRouter_API_Error", status_code=response.status_code, details=response.text) + "\n") sys.exit(1) - if is_image_model: + if not should_stream: data = response.json() usage_data = data.get("usage") @@ -788,6 +3079,323 @@ def main(): result_obj = _extract_images_from_message(msg) + # Bazı image modelleri görseli message içinde değil, + # response JSON'un başka alanlarında döndürebiliyor. + # Ama fallback image bulamazsa mevcut text content'i ezmemeli. + if not result_obj.get("images") and not result_obj.get("images_base64"): + fallback_images = _extract_images_from_anywhere(data) + + if fallback_images.get("images") or fallback_images.get("images_base64"): + old_content = str(result_obj.get("content") or "").strip() + + result_obj = fallback_images + + if old_content and not str(result_obj.get("content") or "").strip(): + result_obj["content"] = old_content + + # AI bazen dosya metadata'sını message.content içinde JSON string olarak döndürür. + # Onu gerçek result_obj haline çeviriyoruz. + content_text = str(result_obj.get("content") or "").strip() + + if content_text.startswith("{"): + try: + parsed_content = json.loads(content_text) + + if isinstance(parsed_content, dict) and ( + parsed_content.get("generated_files_base64") + or parsed_content.get("generated_files_url") + or parsed_content.get("generated_files") + ): + result_obj = parsed_content + + except Exception: + pass + + web_query = _extract_web_search_call(content_text) + + if web_query and web_search_enabled: + if ask_for_web_search and not approved_web_search_query: + result_obj = { + "type": "text", + "content": "", + "web_search_request": { + "query": web_query, + "status": "pending" + } + } + + print(json.dumps(result_obj, ensure_ascii=False)) + return + + query_to_search = approved_web_search_query or web_query + web_results = _tavily_search(cfg, query_to_search, max_results=5) + + final_messages.append({ + "role": "assistant", + "content": content_text + }) + + final_messages.append({ + "role": "system", + "content": ( + "Web search results are below. " + "Answer the user's last question using only these results. " + "If the results are not enough, say that clearly.\n\n" + + web_results + ) + }) + + payload2 = { + "model": model_name, + "messages": final_messages, + "max_tokens": 5120, + "stream": False + } + + response2 = requests.post( + OPENROUTER_URL, + headers={ + "Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json", + "HTTP-Referer": "http://localhost", + "X-Title": "capture-ai" + }, + json=payload2, + timeout=120 + ) + + if response2.status_code != 200: + sys.stderr.write( + get_ui_text( + cfg, + "o_OpenRouter_API_Error", + status_code=response2.status_code, + details=response2.text + ) + "\n" + ) + sys.exit(1) + + data = response2.json() + msg = data["choices"][0]["message"] + result_obj = _extract_images_from_message(msg) + content_text = str(result_obj.get("content") or "").strip() + + # Editable PDF isteğinde AI PDF değil image döndürürse, + # app image'ı PDF dosyasına paketler. + generated_files = [] + + # 1) PDF text -> AI file_create DOCX verir, app DOCX + PDF üretir + if is_pdf_text_only_request and pdf_mode_text: + reply_text = str(result_obj.get("content") or "").strip() + generated_files = _apply_file_create_from_reply(reply_text) + + clean_text = _remove_named_block(reply_text, "file_create") + + result_obj = { + "type": "text", + "content": clean_text or get_ui_text(cfg, "o_PDF_Text_Edited"), + "generated_files": generated_files, + } + + # 2) PDF image + scan PDF -> AI PNG verir, app PDF'e çevirir + elif is_pdf_image_only_request and pdf_mode_image and ( + result_obj.get("images") or result_obj.get("images_base64") + ): + image_paths = _save_result_images_to_files(result_obj) + + pdf_out = _image_paths_to_pdf_file( + image_paths, + output_name="edited-scan-pdf.pdf" + ) + + if pdf_out and pdf_out.exists(): + generated_files.append({ + "path": str(pdf_out.resolve()), + "name": pdf_out.name + }) + + result_obj = { + "type": "text", + "content": get_ui_text(cfg, "o_PDF_Image_Edited"), + "generated_files": generated_files, + } + + # 3) Mixed PDF + sadece image -> AI PNG verir, app eski PDF üstüne image block olarak koyar + elif is_pdf_mixed_request and pdf_mode_image and ( + result_obj.get("images") or result_obj.get("images_base64") + ): + image_paths = _save_result_images_to_files(result_obj) + + pdf_out = None + if mixed_pdf_source and mixed_pdf_layout: + pdf_out = _overlay_images_on_pdf_blocks( + mixed_pdf_source, + mixed_pdf_layout, + image_paths, + output_name="edited-pdf-image.pdf" + ) + + if pdf_out and pdf_out.exists(): + generated_files.append({ + "path": str(pdf_out.resolve()), + "name": pdf_out.name + }) + + result_obj = { + "type": "text", + "content": get_ui_text(cfg, "o_PDF_Image_Block_Edited"), + "generated_files": generated_files, + } + + # 4) Mixed PDF + text only / text image -> AI JSON verir, app layout'u yeniden kurar + elif is_pdf_mixed_request and (pdf_mode_text or pdf_mode_text_image): + parsed = None + + content_text = str(result_obj.get("content") or "").strip() + + if content_text.startswith("{"): + try: + parsed = json.loads(content_text) + except Exception: + parsed = None + + if isinstance(result_obj, dict) and result_obj.get("type") == "pdf_mixed_rewrite": + parsed = result_obj + + if isinstance(parsed, dict) and parsed.get("type") == "pdf_mixed_rewrite": + text_replacements = parsed.get("text_replacements") or {} + image_replacements = parsed.get("image_replacements") or {} + + pdf_out = _create_pdf_from_mixed_layout( + mixed_pdf_layout or {}, + text_replacements, + image_replacements, + output_name="edited-mixed-pdf.pdf" + ) + + if pdf_out and pdf_out.exists(): + generated_files.append({ + "path": str(pdf_out.resolve()), + "name": pdf_out.name + }) + + result_obj = { + "type": "text", + "content": str(parsed.get("content") or get_ui_text(cfg, "o_PDF_Mixed_Edited")), + "generated_files": generated_files, + } + + if is_pdf_mixed_request and isinstance(result_obj, dict): + content_text = str(result_obj.get("content") or "").strip() + + if content_text.startswith("{"): + try: + parsed_content = json.loads(content_text) + if isinstance(parsed_content, dict): + result_obj = parsed_content + except Exception: + pass + + if result_obj.get("type") == "pdf_mixed_rewrite": + text_replacements = result_obj.get("text_replacements") + image_replacements = result_obj.get("image_replacements") + + # Kullanıcı sadece resim değişikliği istediyse, + # AI yazı üretse bile hepsini yok say. + if is_pdf_mixed_image_only_request: + text_replacements = {} + + if mixed_pdf_layout and isinstance(text_replacements, dict): + out = _create_pdf_from_mixed_layout( + mixed_pdf_layout, + text_replacements, + image_replacements if isinstance(image_replacements, dict) else {}, + "edited-mixed-pdf.pdf" + ) + + if out and out.exists(): + result_obj = { + "type": "text", + "content": get_ui_text(cfg, "o_File_Created"), + "generated_files": [{ + "path": str(out.resolve()), + "name": out.name + }] + } + + if usage_obj is not None: + result_obj["usage"] = usage_obj + + generated_files = [] + + # PDF girdiyse ve AI hâlâ PNG/image döndürdüyse: + # PNG kullanıcıya gösterilmesin, PDF'e çevrilsin. + if has_editable_pdf_request and ( + result_obj.get("images") + or result_obj.get("images_base64") + or result_obj.get("image") + or result_obj.get("image_base64") + or result_obj.get("url") + ): + image_paths = _save_result_images_to_files(result_obj) + + pdf_out = None + + if image_paths: + if is_pdf_mixed_request and mixed_pdf_source and mixed_pdf_layout: + pdf_out = _overlay_images_on_pdf_blocks( + mixed_pdf_source, + mixed_pdf_layout, + image_paths, + output_name="edited-pdf-image.pdf" + ) + + if not pdf_out: + pdf_out = _image_paths_to_pdf_file( + image_paths, + output_name="edited-pdf-result.pdf" + ) + + if pdf_out and pdf_out.exists(): + generated_files.append({ + "path": str(pdf_out.resolve()), + "name": pdf_out.name + }) + + result_obj = { + "type": "text", + "content": get_ui_text(cfg, "o_File_Created"), + "generated_files": generated_files, + } + + elif isinstance(result_obj.get("generated_files"), list): + generated_files = result_obj["generated_files"] + + else: + generated_files += _apply_ai_returned_files_from_result(result_obj) + generated_files += _apply_file_create_from_reply(result_obj.get("content", "")) + + + if generated_files: + clean_content = _remove_named_block( + result_obj.get("content", ""), + "file_create" + ) + + final_text = clean_content.strip() + + if not final_text: + final_text = get_ui_text(cfg, "o_File_Created") + + result_obj = { + "type": "text", + "content": final_text, + "generated_files": generated_files + } + + if usage_obj is not None: + result_obj["usage"] = usage_obj + # hiç image yoksa düz text fallback if not result_obj.get("content") and not result_obj.get("images") and not result_obj.get("images_base64"): txt = _message_to_plain_text(msg.get("content")) @@ -855,20 +3463,93 @@ def main(): reply = fix_mojibake(reply) if not reply: - sys.stderr.write("OpenRouter boş stream döndürdü.\n") + sys.stderr.write(get_ui_text(cfg, "o_OpenRouter_Empty_Stream") + "\n") sys.exit(1) except requests.exceptions.Timeout: - sys.stderr.write("İstek zaman aşımına uğradı (timeout).\n") + sys.stderr.write(get_ui_text(cfg, "o_Request_Timeout") + "\n") sys.exit(1) except requests.exceptions.ConnectionError: - sys.stderr.write("Bağlantı hatası. İnternet bağlantınızı kontrol edin.\n") + if is_local_model: + try: + provider, _actual_model = _split_local_model(model_name) + except Exception: + provider = "local" + + provider_cfg = local_providers.get(provider, {}) if isinstance(local_providers, dict) else {} + + base_url = str(provider_cfg.get("base_url") or "").strip() + custom_error = str(provider_cfg.get("system_error") or "").strip() + + # custom error varsa onu kullan + if custom_error: + msg = custom_error.replace( + "{base_url}", + base_url or "http://127.0.0.1:11434" + ) + else: + # default local error + msg = get_ui_text( + cfg, + "o_Local_Connection_Error", + provider=provider, + base_url=base_url or "http://127.0.0.1:11434" + ) + + # chat formatında döndür + result_obj = { + "type": "text", + "content": msg + } + + sys.stdout.write(json.dumps(result_obj, ensure_ascii=False)) + sys.stdout.flush() + + else: + # 🌐 online model + result_obj = { + "type": "text", + "content": get_ui_text(cfg, "o_Internet_Connection_Error") + } + + sys.stdout.write(json.dumps(result_obj, ensure_ascii=False)) + sys.stdout.flush() + sys.exit(1) except Exception as e: - sys.stderr.write(f"AI ERROR: {str(e)}\n") + sys.stderr.write(get_ui_text(cfg, "o_AI_Error", error=str(e)) + "\n") sys.exit(1) + + # ---------------- SAVE ASSISTANT MEMORY ---------------- + if context_mode == "rag": + try: + assistant_text = "" + + # image model / local model bazen JSON string döndürüyor + try: + parsed_reply = json.loads(reply) + if isinstance(parsed_reply, dict): + assistant_text = str(parsed_reply.get("content") or "").strip() + except Exception: + assistant_text = str(reply or "").strip() + + if assistant_text: + add_memory_chunk( + chat_data, + assistant_text[:memory_chunk_max_chars], + source="chat:assistant" + ) + + if use_summary and should_update_summary(chat_data, every=summary_update_every): + chat_data["summary"] = make_simple_summary(chat_data)[:summary_max_chars] + + save_chat_data(chat_file, chat_data) + + except Exception: + pass + sys.exit(0) if __name__ == "__main__": diff --git a/capture-ai.sh b/capture-ai.sh index adc0e01..b4723e9 100644 --- a/capture-ai.sh +++ b/capture-ai.sh @@ -82,8 +82,8 @@ sys.exit(1) *) echo "Usage:" - echo " capture-ai.sh image - Take a screenshot and start using it directly in the app" - echo " capture-ai.sh text - Start the app without an image (text mode)" - echo " capture-ai.sh cli - Run the app in terminal mode" + echo " capture-ai.sh image" + echo " capture-ai.sh text" + echo " capture-ai.sh cli" ;; esac diff --git a/language/en.json b/language/en.json new file mode 100644 index 0000000..41709cf --- /dev/null +++ b/language/en.json @@ -0,0 +1,266 @@ +{ + "o_Toggle_Sidebar": "Show/Hide", + "o_Context_RAG": "RAG", + "o_Context_Direct": "Direct", + "o_Context_Mode": "RAG: uses summary, recent messages, code context, and relevant past chunks.\nDirect: sends only the current message and selected references.", + "o_Add_Model": "Add Model", + "o_Delete_Model": "Delete Model", + "o_Delete_Model_Confirm": "Do you want to delete this model?\n\n{model_id}", + "o_Min_Model_Title": "Model can't be deleted", + "o_Min_Model_Error": "At least one model must remain. You cannot delete the last model.", + "o_New_Chat": "New Chat", + "o_Options": "Options", + "o_Toggle_Dark_Mode": "Toggle Dark Mode", + "o_Settings": "Settings", + "o_Suggestions_Title": "Try asking something", + "o_Suggestion_1": "Tell me a joke", + "o_Suggestion_2": "Summarize this text", + "o_Suggestion_3": "Give me an idea for a project", + "o_Suggestion_4": "Explain a topic simply", + "o_Suggestion_5": "Help me write an email", + "o_Attachments": "Take photo, Attach file", + "o_Prompt_Chooser": "Prompt Chooser", + "o_PC_Copyable": "Copyable Content", + "o_PC_Apply": "Apply Changes to Files", + "o_PC_FileCreate": "Create New Files (DOCX / XLSX)", + "o_PC_WebSearch": "Web search", + "o_PC_Structured": "Structured Output", + "o_PC_Code": "Code Generation / Editing", + "o_PC_PDFText": "PDF Text", + "o_PC_PDFImage": "PDF Image", + "o_PC_PDFTextImage": "PDF Text + Image", + "o_PDF_Mode_Required": "You must select a PDF mode from the Prompt Chooser:\n- PDF Text\n- PDF Image\n- PDF Text + Image", + "o_PDF_Text_Edited": "PDF text was edited and the file was created.", + "o_PDF_Image_Edited": "PDF image was edited and converted to PDF.", + "o_PDF_Image_Block_Edited": "PDF image block was edited and the PDF was created.", + "o_PDF_Mixed_Edited": "Mixed PDF was edited and the file was created.", + "o_PDF_Convert_Failed": "PDF could not be converted to images. Check the PDF conversion setup.", + "o_PDF_Has_Text_Select_Text_Model": "This PDF contains extractable text. Please select PDF Text mode instead of PDF Image mode.", + "o_PDF_No_Images_Found": "No images were found in this PDF.", + "o_PDF_Text_Extract_Failed": "PDF text could not be extracted.\nDo not ask for the file path.\nUse OCR or PDF Image mode.", + "o_Microphone": "Dictate", + "o_Stop_Recording": "Stop Recording", + "o_Send_Button": "Send", + "o_Stop_Button": "Stop", + "o_Scroll_Left": "Scroll left", + "o_Scroll_Right": "Scroll right", + "o_Clear_Selection": "Clear selection", + "o_Image": "Image", + "o_Image_Could_Not_Be_Loaded": "Image could not be loaded:\n{path}", + "o_Zoom_Attachments": "Zoom in", + "o_Close_Attachments": "Close", + "o_Scroll_Down": "Scroll down", + "o_AI_Models": "AI Models", + "o_Chats": "Chats", + "o_Delete_Chat": "Delete chat", + "o_Delete_Chat_Confirm": "Do you want to delete this chat?\nThis action cannot be undone.", + "o_Rename": "Rename", + "o_New_Name": "New Name:", + "o_Rename_Hint": "(This will be used as the file name)", + "o_Pin": "Pin", + "o_Unpin": "Unpin", + "o_OpenRouter-Tavily_API_Key": "🔑 OpenRouter/Tavily Key", + "o_OpenRouter_API_Key": "🔑 OpenRouter API Key", + "o_Tavily_API_Key": "🔑 Tavily Key", + "o_Response_Style": "🧠 Response style", + "o_Personalization": "🎨 Personalization", + "o_Language": "🇬🇧 Language", + "o_RAG_Settings": "🧩 RAG Settings", + "o_AI_Settings": "🤖 Local AI Settings", + "o_STT_Settings": "🎙️ STT Settings", + "o_Input_Tokens": "Input tokens", + "o_Output_Tokens": "Output tokens", + "o_Total_Tokens": "Total", + "o_Copy": "Copy", + "o_Download": "Download", + "o_Image_Created": "Image created.", + "o_File_Saved": "File saved.", + "o_File_Not_Found": "File not found.", + "o_Thinking": "Thinking", + "o_Take_Photo": "Take Photo", + "o_Attach_File": "Attach File", + "o_Reference_Message": "message selected as reference", + "o_Apply": "Apply", + "o_Apply_Mode_replace": "Replacing", + "o_Apply_Mode_before": "Inserting before", + "o_Apply_Mode_after": "Inserting after", + "o_Reject": "Reject", + "o_Save": "Save", + "o_Cancel": "Cancel", + "o_Search": "Search", + "o_Web_Search_Request": "Web search request", + "o_Web_Searching": "Searching...", + "o_Web_Search_Searched": "Searched", + "o_Web_Search_Cancelled": "Web search cancelled", + "o_Web_Search_Request_With_Query": "AI wants to search the web:\n{query}", + "o_Web_Search_API_Key_Invalid": "Web search API key is invalid.", + "o_Web_Search_API_Error": "Web search API error ({status_code}).", + "o_Web_Search_Failed": "Search failed", + "o_Web_Search_Error_With_Detail": "Search failed: {error}", + "o_OK": "OK", + "o_Add": "Add", + "o_Delete": "Delete", + "o_File_Created": "File created.", + "o_File_Changed": "File modified", + "o_Cancelled": "Operation cancelled", + "o_Generation_Failed": "Failed to generate response", + "o_OpenRouter_Placeholder": "Enter OpenRouter API Key...", + "o_Tavily_Placeholder": "Enter Tavily API Key...", + "o_Response_Style_Placeholder": "Example: Speak in a friendly and humorous tone", + "o_Window_BG": "Window background", + "o_Input_BG": "Input background", + "o_User_BG": "User bubble background", + "o_User_Text": "User text color", + "o_Bot_BG": "Bot bubble background", + "o_Bot_Text": "Bot text color", + "o_Sidebar_Text": "Sidebar text color", + "o_Local_Model_Color": "Local model highlight color", + "o_Token_Usage": "Token Usage", + "o_Use_Token_Value": "Show Token Value", + "o_Token_Value": "Token Value", + "o_Token_Value_Hint": "Price per 1M tokens", + "o_Total_Token_Price": "Total Token Price", + "o_Color_Edit_Hint": "You are editing {mode_name} colors.\nUse hex format: e.g. #303643", + "o_Background_Hint": "Chat Background Image", + "o_Background_Path_Placeholder": "/file-path/photo.png", + "o_Clear": "Clear", + "o_Select_Image": "Select Image", + "o_Reset_Default": "Reset to default", + "o_RAG_Recent_Count": "Recent message count", + "o_RAG_Recent_Count_Hint": "Number of recent conversation messages sent to the model in RAG mode.", + "o_RAG_Retrieved_Count": "Relevant old message count", + "o_RAG_Retrieved_Count_Hint": "Number of relevant old chunks retrieved from previous conversation.", + "o_RAG_Summary_Every": "Summary update interval", + "o_RAG_Summary_Every_Hint": "How often the conversation summary is updated.", + "o_RAG_Memory_Chunk_Max": "Memory chunk max length", + "o_RAG_Memory_Chunk_Max_Hint": "Maximum character length of a single saved memory chunk.", + "o_RAG_Summary_Max": "Summary max length", + "o_RAG_Summary_Max_Hint": "Maximum character length of the conversation summary.", + "o_RAG_Code_Context_Max": "Code context max length", + "o_RAG_Code_Context_Max_Hint": "Maximum character length of code context sent to the model.", + "o_RAG_Use_Summary": "Use summary", + "o_RAG_Use_Summary_Hint": "Adds the conversation summary to the context when enabled.", + "o_RAG_Use_Recent": "Use recent messages", + "o_RAG_Use_Recent_Hint": "Adds recent messages to the context when enabled.", + "o_RAG_Use_Retrieval": "Use relevant old messages", + "o_RAG_Use_Retrieval_Hint": "Retrieves relevant old chunks from previous conversation when enabled.", + "o_RAG_Use_Code_Context": "Use code context", + "o_RAG_Use_Code_Context_Hint": "Adds summaries of attached code files to the context when enabled.", + "o_RAG_Recent_Attachments": "Send attachments in recent messages", + "o_RAG_Recent_Attachments_Hint": "When disabled, only text from recent messages is sent; old files/images are not resent.", + "o_Force_Language_Text": "Use UI Language for Responses", + "o_Force_Language_Hint": "AI should always respond in the selected language.", + "o_Ask_Web_Search": "Ask before web search", + "o_Ask_Web_Search_Hint": "The AI must ask for your permission before it performs a web search.", + "o_AI_Providers": "AI Providers", + "o_Selected_Provider": "Selected Provider", + "o_Add_Provider": "Add Provider", + "o_Stop_Provider": "Stop Provider", + "o_Provider_Cant_Be_Deleted": "Provider can't be deleted", + "o_Provider_Min_One_Required": "At least one provider must remain.", + "o_Delete_Provider": "Delete Provider", + "o_Delete_Provider_Confirm": "Do you want to delete this provider?\n\n{provider}", + "o_Provider_Name": "Provider Name", + "o_Enable_Provider": "Enable Provider", + "o_Enable_Provider_Hint": "Enable this AI provider for local models.", + "o_Run_Startup": "Run Startup", + "o_Run_Startup_Placeholder": "Enter a command or a script path to start the provider.", + "o_Stop_Command": "Stop Command", + "o_Stop_Command_Placeholder": "Exp: pkill -f 'ollama serve'", + "o_Custom_Error": "Custom Error Message", + "o_Custom_Error_Placeholder": "Write a custom error message that will be displayed if the provider cannot respond.", + "o_System_Prompt": "System Prompt", + "o_Online_STT_Text": "Online STT", + "o_Online_STT_Hint": "If enabled, speech-to-text is processed via OpenRouter.", + "o_Use_Desktop_Audio_Text": "Use Desktop Audio", + "o_Use_Desktop_Audio_Hint": "If enabled, system audio will be recorded instead of the microphone.", + "o_Online_STT_Model": "Online STT Model", + "o_Binary_Path": "Binary Path", + "o_Model_Path": "Model Path", + "o_Active_Chat": "Active Chat", + "o_Active_Model": "Active Model", + "o_Pending_Attachments": "Pending attachments", + "o_image": "image", + "o_file": "file", + "o_Pin_Current_Chat": "Pin Current Chat", + "o_Unpin_Current_Chat": "Unpin Current Chat", + "o_Send_Message": "Send Message", + "o_sub_Send_Message": "Type your message. Press Enter on an empty line to finish.", + "o_Empty_Message": "Empty message was not sent.", + "o_AI_Response": "Sent. Waiting for AI response...", + "o_Drag_Drop": "Enter a file path or drag and drop the file", + "o_to_Menu": "Press Enter to return to the menu...", + "o_Empty_Input": "You cannot enter an empty value.", + "o_Type_ESC": "To cancel, type “esc” on the first line and press Enter.", + "o_Press_ESC": "You can cancel the selection by pressing ESC.", + "o_Change_Chat": "Change Chat", + "o_Rename_Chat": "Chat to be renamed", + "o_New_Value": "Please enter a new value:", + "o_Chat_Rename_Fail": "The new name must be different from the current name.", + "o_AI_Model": "AI Model", + "o_Change_AI_Model": "Change AI Model", + "o_Available_Languages": "Available languages", + "o_Language_Select_Hint": "You can choose one of the language codes below.", + "o_Invalid_Language_Code": "Invalid language code. This language was not found in the config.", + "o_Saved": "Saved", + "o_Go_Back": "Go Back", + "o_Exit": "Exit", + "o_Selection": "Selection", + "o_Error": "Error", + "o_Timeout": "Timeout", + "o_Failed": "Failed", + "o_Error_With_Detail": "❌ Error:\n{error}", + "o_Config_Not_Found": "Config file not found.", + "o_Config_Read_Error": "Config could not be read: {error}", + "o_Chat_Read_Error": "Chat could not be read: {error}", + "o_OpenRouter_Key_Missing": "OpenRouter API Key not found.", + "o_Invalid_Local_Model_Format": "Invalid local model format: {model}. Expected: provider/model", + "o_Local_Provider_Not_Found": "Local provider not found: {provider}", + "o_Local_Provider_Disabled": "Local provider is disabled: {provider}", + "o_Unsupported_Local_Provider": "Unsupported local provider: {provider}", + "o_OpenRouter_API_Error": "OpenRouter API error ({status_code}): {details}", + "o_OpenRouter_Empty_Stream": "OpenRouter returned an empty stream.", + "o_Request_Timeout": "The request timed out.", + "o_Internet_Connection_Error": "Connection error. Check your internet connection.", + "o_Local_Connection_Error": "{provider} connection error.\nThe local AI server could not be reached.\nExpected address: {base_url}\nThe {provider} server may not be running.", + "o_Ollama_API_Error": "Ollama API error ({status_code}): {details}", + "o_Ollama_JSON_Parse_Error": "Ollama JSON parse error: {error}", + "o_Ollama_Empty_Response": "Ollama returned an empty response.", + "o_Script_Not_Found": "Script not found: {path}", + "o_Photo_Capture_Error": "Photo capture error: {error}", + "o_STT_Error": "STT error: {error}", + "o_No_Speech_Detected": "No speech detected.", + "o_OpenRouter_STT_Key_Missing": "open_router_key is missing", + "o_Whisper_Bin_Not_Found": "whisper_cpp_bin not found: {path}", + "o_Whisper_Model_Not_Found": "whisper_cpp_model not found: {path}", + "o_Whisper_Failed": "whisper.cpp failed", + "o_Desktop_Audio_Monitor_Not_Found": "Monitor source for desktop audio was not found.", + "o_Microphone_Source_Not_Found": "Microphone source was not found.", + "o_Desktop_Audio_Requires_PWRecord": "pw-record is required for desktop audio capture.", + "o_Microphone_Device_Not_Found": "Microphone device was not found.", + "o_Audio_Recorder_Not_Found": "pw-record or arecord was not found.", + "o_Audio_Record_Start_Failed": "Audio recording could not be started: {error}", + "o_Unknown_Error": "unknown error", + "o_Apply_Failed_Input": "❌ APPLY FAILED: {error}", + "o_Apply_No_Ops": "no ops to apply", + "o_Apply_File_Not_Found": "file not found: {path}", + "o_Apply_Find_Failed": "find failed: anchor not found in {name}", + "o_Apply_Unknown_Mode": "unknown mode: {mode}", + "o_Apply_No_Changes": "no changes produced (maybe identical content?)", + "o_Apply_Exception": "exception: {error}", + "o_Provider_Command_No_Command": "{provider}\n\nNo command is set.", + "o_Provider_Command_Timeout": "{provider}\n\nCommand timed out:\n{command}", + "o_Provider_Command_Error": "{provider}\n\n{error}", + "o_Provider_Command_Success": "{provider}\n\nCommand executed successfully.\n\n{command}", + "o_Provider_Command_Failed": "{provider}\n\nCommand failed.\n\n{command}\n\nExit code: {code}", + "o_Start_Local_Server_From_App": "Start the local server from the {provider} app.", + "o_Base_System_Prompt": "This message is a set of instructions.\nDo not respond to this message.\nOnly respond to user messages.\nOnly respond to the most recent user message.", + "o_Prompt_Copyable": "In this conversation, you may be asked to generate emails, code, commands, configuration files, or text that needs to be copied exactly.\n\nFORMAT RULE:\nIf the user's request requires generating directly copyable content (such as code, scripts, terminal commands, configuration files, etc.), use the following format:\n\ntitle\n\ntitle\ncopy\n\ncopy\n\nRules:\n- The words 'title' and 'copy' must be on their own lines.\n- The 'title' block must appear before the code block.\n- Do not use this format if there is no code.\n- Do not use this format in normal conversational responses.\n- Only use this format when the content is genuinely meant to be copied.", + "o_Prompt_Apply": "[FILE] BLOCKS:\n- If the user has attached a file, its content is already provided in this message in the format [FILE] ... [CONTENT] ... [/CONTENT].\n- Do not ask the user to paste code or re-upload the file.\n- If changes are required, read and operate on the [CONTENT].\n- Do not request the file content again.\n- Use the path value exactly as provided in the [FILE] block.\n\nFILE MODIFICATION RULE:\n- If 'editable: true', you may suggest modifications.\n- If 'editable: false', NEVER suggest changes.\n- Do not generate an apply block for editable:false files.\n- If no changes are needed, do not generate an apply block.\n\nAPPLY (AUTOMATIC PATCH) PROTOCOL:\nIf and only if the user requests changes to an editable:true file,\nappend the following block at the END of your response:\n\napply\n[\n {\n \"path\": \"/absolute/path/to/file\",\n \"find\": \"EXACT ANCHOR TEXT FOUND IN FILE\",\n \"mode\": \"before|after|replace\",\n \"text\": \"TEXT TO INSERT OR REPLACE\"\n }\n]\napply\n\nRules:\n- The apply block must contain ONLY JSON.\n- mode must be one of: before, after, replace.\n- path MUST be an absolute path.\n- find must be a short/medium anchor that exists exactly in the file.\n\nAPPLY SUMMARY LINE RULE:\n- If you generate an apply block, NEVER use definitive/finished statements.\n- Write exactly ONE summary line before the apply block.", + "o_Prompt_File_Create": "[FILE] BLOCKS:\n- If previous assistant messages contain a [GENERATED_FILES] block, and the user refers to 'the last file', 'the file you created', etc., use those path values.\n- Do not ask the user to upload the file again.\n- If file content is already provided in a [FILE] block, do not request it again.\n\nFILE_CREATE PROTOCOL:\nIf the user wants to edit an editable:true DOCX, XLSX, or text-based file and receive it as a new file, append the following block at the END of your response:\n\nfile_create\n[\n {\n \"source_path\": \"/absolute/path/to/source.docx\",\n \"format\": \"docx\",\n \"output_name\": \"edited-file.docx\",\n \"content\": \"New file content\"\n }\n]\nfile_create\n\nRules:\n- Do not include explanations outside JSON.\n- format must be one of: docx, xlsx, txt, md.\n- For DOCX, content must be plain text.\n- For XLSX, content must be a JSON table or supported operation object.\n- The content must be a 2D JSON array whenever possible.\n- Each inner array represents one row in Excel.\n- Avoid rewriting large XLSX files row by row.\n- Do not wrap the entire table inside one row.\n- Each row must be separate.", + "o_Prompt_Web_Search": "If current or unknown information is needed, do not answer normally. Instead, return only this JSON format:\n{\"tool\":\"web_search\",\"query\":\"search query\"}\n\nRules:\n- Use this only when current information is really needed.\n- Use it for match scores, current prices, latest news, new versions, today's information, etc.\n- If web results are provided to you, answer normally using only those results.\n- When web search results are provided, include a short 'Sources' section at the end of your answer. Use the source titles and URLs exactly as provided. Do not invent sources.\n- When web search results are used, write source URLs as full https:// links.", + "o_Prompt_Structured": "When needed, provide responses in a clean, structured, and parseable format. If JSON is requested, return only valid JSON.", + "o_Prompt_Code": "When writing code, produce clean, directly usable code without unnecessary explanation. If modifying existing code, preserve the original structure as much as possible.", + "o_Prompt_PDF_Text_Only_To_DOCX_Rule": "PDF TEXT REWRITE RULE:\nThe PDF text is already provided inside [CONTENT].\nDo not request file paths.\nDo not ask the user to upload the file again.\n\nRules:\n- Do not generate images.\n- Do not use HTML, markdown, or image placeholders.\n- Output plain text only.\n- Rewrite according to the request while preserving meaning.\n- Do not invent new content.\n\nReturn ONLY in file_create DOCX format:\nfile_create\n[\n {\n \"source_path\": \"/absolute/path/to/source.pdf\",\n \"format\": \"docx\",\n \"output_name\": \"edited-document.docx\",\n \"content\": \"Final rewritten text\"\n }\n]\nfile_create", + "o_Prompt_PDF_Mixed_Text_Only_Rule": "PDF MIXED TEXT-ONLY RULE:\nThe PDF contains both text and images, but only text should be modified.\nDo not modify images.\n\nRules:\n- Rewrite text only.\n- Do not invent new content.\n- Do not modify images.\n\nReturn ONLY JSON:\n{\n \"type\": \"pdf_mixed_rewrite\",\n \"content\": \"File created.\",\n \"text_replacements\": {\n \"TEXT_BLOCK_ID\": \"new text\"\n },\n \"image_replacements\": {}\n}" + } diff --git a/language/tr.json b/language/tr.json new file mode 100644 index 0000000..16e0740 --- /dev/null +++ b/language/tr.json @@ -0,0 +1,266 @@ +{ + "o_Toggle_Sidebar": "Göster/Gizle", + "o_Context_RAG": "RAG", + "o_Context_Direct": "Direkt", + "o_Context_Mode": "RAG: özet + son mesajlar + kod bağlamı + ilgili eski konuşmalar.\nDirekt: sadece mevcut mesaj ve seçili referanslar gönderilir.", + "o_Add_Model": "Model Ekle", + "o_Delete_Model": "Modeli Sil", + "o_Delete_Model_Confirm": "Bu modeli silmek istiyor musun?\n\n{model_id}", + "o_Min_Model_Title": "Model Silinemez", + "o_Min_Model_Error": "En az 1 model kalmalı. Son modeli silemezsin.", + "o_New_Chat": "Yeni Sohbet", + "o_Options": "Seçenekler", + "o_Toggle_Dark_Mode": "Karanlık Modu Tetikle", + "o_Settings": "Ayarlar", + "o_Suggestions_Title": "Bir şey sormayı dene", + "o_Suggestion_1": "Bana bir fıkra anlat", + "o_Suggestion_2": "Bu metni özetle", + "o_Suggestion_3": "Bana bir proje fikri ver", + "o_Suggestion_4": "Bir konuyu basitçe anlat", + "o_Suggestion_5": "E-posta yazmama yardım et", + "o_Attachments": "Fotoğraf çek, Dosya ekle", + "o_Prompt_Chooser": "Girdi Seçici", + "o_PC_Copyable": "Kopyalanabilir İçerik", + "o_PC_Apply": "Dosyada Değişiklik", + "o_PC_FileCreate": "Yeni Dosya Oluştur (DOCX / XLSX)", + "o_PC_WebSearch": "Ağ araması", + "o_PC_Structured": "Yapılandırılmış Çıktı", + "o_PC_Code": "Kod Üretimi / Düzenleme", + "o_PC_PDFText": "PDF Yazı", + "o_PC_PDFImage": "PDF Resim", + "o_PC_PDFTextImage": "PDF Yazı + Resim", + "o_PDF_Mode_Required": "PDF düzenleme için Prompt Chooser içinden bir PDF modu seçmelisin:\n- PDF Yazı\n- PDF Resim\n- PDF Yazı + Resim", + "o_PDF_Text_Edited": "PDF metni düzenlendi ve dosya oluşturuldu.", + "o_PDF_Image_Edited": "PDF görseli düzenlendi ve PDF olarak oluşturuldu.", + "o_PDF_Image_Block_Edited": "PDF görsel bloğu düzenlendi ve PDF oluşturuldu.", + "o_PDF_Mixed_Edited": "Karışık PDF düzenlendi ve dosya oluşturuldu.", + "o_PDF_Convert_Failed": "PDF görsellere dönüştürülemedi. PDF dönüştürme kurulumunu kontrol et.", + "o_PDF_Has_Text_Select_Text_Model": "Bu PDF çıkarılabilir metin içeriyor. PDF Image modu yerine PDF Text modunu seç.", + "o_PDF_No_Images_Found": "Bu PDF içinde görsel bulunamadı.", + "o_PDF_Text_Extract_Failed": "PDF metni çıkarılamadı.\nDosya yolunu isteme.\nOCR veya PDF Image modunu kullan.", + "o_Microphone": "Dikte et", + "o_Stop_Recording": "Kaydı Durdur", + "o_Send_Button": "Gönder", + "o_Stop_Button": "Durdur", + "o_Scroll_Left": "Sola kaydır", + "o_Scroll_Right": "Sağa kaydır", + "o_Clear_Selection": "Seçimi temizle", + "o_Image": "Görsel", + "o_Image_Could_Not_Be_Loaded": "Görsel yüklenemedi:\n{path}", + "o_Zoom_Attachments": "Yakınlaştır", + "o_Close_Attachments": "Kapat", + "o_Scroll_Down": "Aşağı kaydır", + "o_AI_Models": "AI Modelleri", + "o_Chats": "Sohbetler", + "o_Delete_Chat": "Sohbeti Sil", + "o_Delete_Chat_Confirm": "Bu sohbeti silmek istiyor musun?\n\nBu işlem geri alınamaz.", + "o_Rename": "İsim'i Değiştir", + "o_New_Name": "Yeni İsim:", + "o_Rename_Hint": "(Dosya adı olarak kaydedilir)", + "o_Pin": "Sabitle", + "o_Unpin": "Sabiti Kaldır", + "o_OpenRouter-Tavily_API_Key": "🔑 OpenRouter/Tavily Anahtarı", + "o_OpenRouter_API_Key": "🔑 OpenRouter Anahtarı", + "o_Tavily_API_Key": "🔑 Tavily Anahtarı", + "o_Response_Style": "🧠 Cevap Tarzı", + "o_Personalization": "🎨 Kişiselleştirme", + "o_Language": "🇹🇷 Dil", + "o_RAG_Settings": "🧩 RAG Ayarları", + "o_AI_Settings": "🤖 Yerel AI Ayarları", + "o_STT_Settings": "🎙️ STT Ayarları", + "o_Input_Tokens": "Girdi token", + "o_Output_Tokens": "Çıktı token", + "o_Total_Tokens": "Toplam", + "o_Copy": "Kopyala", + "o_Download": "İndir", + "o_Image_Created": "Görsel oluşturuldu.", + "o_File_Saved": "Dosya kaydedildi.", + "o_File_Not_Found": "Dosya bulunamadı.", + "o_Thinking": "Düşünüyor", + "o_Take_Photo": "Fotoğraf Çek", + "o_Attach_File": "Belge Ekle", + "o_Reference_Message": "mesaj referans seçildi", + "o_Apply": "Uygula", + "o_Apply_Mode_replace": "Bu satırı değiştiriyorum", + "o_Apply_Mode_before": "Bu satırın öncesine ekliyorum", + "o_Apply_Mode_after": "Bu satırın sonrasına ekliyorum", + "o_Reject": "Reddet", + "o_Save": "Kaydet", + "o_Cancel": "İptal", + "o_Search": "Ara", + "o_Web_Search_Request": "Ağ araması isteği", + "o_Web_Searching": "Aranıyor...", + "o_Web_Search_Searched": "Arandı", + "o_Web_Search_Cancelled": "Ağ araması iptal edildi", + "o_Web_Search_Request_With_Query": "AI ağda arama yapmak istiyor:\n{query}", + "o_Web_Search_API_Key_Invalid": "Web arama API anahtarı geçersiz.", + "o_Web_Search_API_Error": "Web arama API hatası ({status_code}).", + "o_Web_Search_Failed": "Arama başarısız", + "o_Web_Search_Error_With_Detail": "Arama başarısız: {error}", + "o_OK": "Tamam", + "o_Add": "Ekle", + "o_Delete": "Sil", + "o_File_Created": "Dosya oluşturuldu.", + "o_File_Changed": "Dosya değiştirildi", + "o_Cancelled": "İşlem iptal edildi", + "o_Generation_Failed": "Yanıt oluşturulamadı", + "o_OpenRouter_Placeholder": "OpenRouter API Anahtarı girin...", + "o_Tavily_Placeholder": "Tavily API Anahtarı girin...", + "o_Response_Style_Placeholder": "Örn: Samimi ve esprili konuş", + "o_Window_BG": "Genel arkaplan", + "o_Input_BG": "Input arkaplan", + "o_User_BG": "Kullanıcı balon arkaplan", + "o_User_Text": "Kullanıcı yazı rengi", + "o_Bot_BG": "Bot balon arkaplan", + "o_Bot_Text": "Bot yazı rengi", + "o_Sidebar_Text": "Sidebar yazı rengi", + "o_Local_Model_Color": "Yerel model vurgulama rengi", + "o_Token_Usage": "Jeton Kullanımı", + "o_Use_Token_Value": "Token Değerini Göster", + "o_Token_Value": "Token Değeri", + "o_Token_Value_Hint": "1M token başına ücret", + "o_Total_Token_Price": "Toplam Token Ücreti", + "o_Color_Edit_Hint": "Şu anda {mode_name} renklerini düzenliyorsun.\nHex formatı kullan: örn #303643", + "o_Background_Hint": "Sohbet Arkaplan Resmi", + "o_Background_Path_Placeholder": "/dosya-yolu/resim.png", + "o_Clear": "Temizle", + "o_Select_Image": "Resim Seç", + "o_Reset_Default": "Varsayılana dön", + "o_RAG_Recent_Count": "Son mesaj sayısı", + "o_RAG_Recent_Count_Hint": "RAG modunda modele gönderilecek son konuşma mesajı sayısı.", + "o_RAG_Retrieved_Count": "İlgili eski mesaj sayısı", + "o_RAG_Retrieved_Count_Hint": "Eski konuşmalardan çekilecek alakalı parça sayısı.", + "o_RAG_Summary_Every": "Özet güncelleme aralığı", + "o_RAG_Summary_Every_Hint": "Kaç mesajda bir konuşma özeti güncellensin.", + "o_RAG_Memory_Chunk_Max": "Memory parçası maksimum uzunluğu", + "o_RAG_Memory_Chunk_Max_Hint": "Memory içine kaydedilecek tek parçanın maksimum karakter sayısı.", + "o_RAG_Summary_Max": "Özet maksimum uzunluğu", + "o_RAG_Summary_Max_Hint": "Konuşma özetinin maksimum karakter sayısı.", + "o_RAG_Code_Context_Max": "Kod bağlamı maksimum uzunluğu", + "o_RAG_Code_Context_Max_Hint": "Kod dosyası özetlerinin modele gönderilecek maksimum karakter sayısı.", + "o_RAG_Use_Summary": "Özet kullan", + "o_RAG_Use_Summary_Hint": "Açık olursa konuşma özeti bağlama eklenir.", + "o_RAG_Use_Recent": "Son mesajları kullan", + "o_RAG_Use_Recent_Hint": "Açık olursa son mesajlar bağlama eklenir.", + "o_RAG_Use_Retrieval": "Eski alakalı mesajları kullan", + "o_RAG_Use_Retrieval_Hint": "Açık olursa eski konuşmalardan alakalı parçalar çekilir.", + "o_RAG_Use_Code_Context": "Kod bağlamı kullan", + "o_RAG_Use_Code_Context_Hint": "Açık olursa eklenen kod dosyalarının özeti bağlama eklenir.", + "o_RAG_Recent_Attachments": "Son mesajlardaki dosyaları gönder", + "o_RAG_Recent_Attachments_Hint": "Kapalı olursa son mesajların sadece yazıları gider; eski dosya/resimler tekrar gönderilmez.", + "o_Force_Language_Text": "Yanıtlar için Arayüz Dilini Kullan", + "o_Force_Language_Hint": "AI her zaman seçili dilde cevap versin.", + "o_Ask_Web_Search": "Ağ aramasından önce sor", + "o_Ask_Web_Search_Hint": "AI, ağ araması yapmadan önce kullanıcıdan izin almak zorunda olsun.", + "o_AI_Providers": "AI Sağlayıcıları", + "o_Selected_Provider": "Seçili Sağlayıcı", + "o_Add_Provider": "Sağlayıcı Ekle", + "o_Stop_Provider": "Sağlayıcıyı Durdur", + "o_Provider_Cant_Be_Deleted": "Sağlayıcı silinemez", + "o_Provider_Min_One_Required": "En az bir sağlayıcı kalmalı.", + "o_Delete_Provider": "Sağlayıcıyı Sil", + "o_Delete_Provider_Confirm": "Bu sağlayıcı silinsin mi?\n\n{provider}", + "o_Provider_Name": "Sağlayıcı Adı", + "o_Enable_Provider": "Sağlayıcıyı Etkinleştir", + "o_Enable_Provider_Hint": "Bu sağlayıcıyı yerel modeller için aç.", + "o_Run_Startup": "Başlatma Komutu", + "o_Run_Startup_Placeholder": "Sağlayıcıyı başlatmak için komut veya script gir.", + "o_Stop_Command": "Durdurma Komutu", + "o_Stop_Command_Placeholder": "Örn: pkill -f 'ollama serve'", + "o_Custom_Error": "Özel Hata Mesajı", + "o_Custom_Error_Placeholder": "Sağlayıcı hata verirse gösterilecek mesajı yaz.", + "o_System_Prompt": "Sistem Mesajı", + "o_Online_STT_Text": "Çevrimiçi STT", + "o_Online_STT_Hint": "Açık ise OpenRouter üzerinden speech-to-text kullanılır.", + "o_Use_Desktop_Audio_Text": "Masaüstü Sesi Kullan", + "o_Use_Desktop_Audio_Hint": "Açık ise mikrofon yerine masaüstünde çalan ses kaydedilmeye çalışılır.", + "o_Online_STT_Model": "Çevrimiçi STT Model", + "o_Binary_Path": "Çalıştırılabilir Dosya Yolu", + "o_Model_Path": "Model Dosya Yolu", + "o_Active_Chat": "Aktif Sohbet", + "o_Active_Model": "Aktif Model", + "o_Pending_Attachments": "Bekleyen ekler", + "o_image": "görüntü", + "o_file": "dosya", + "o_Pin_Current_Chat": "Mevcut Sohbeti Sabitle", + "o_Unpin_Current_Chat": "Mevcut Sohbetin Sabitini Kaldır", + "o_Send_Message": "Mesaj Gönder", + "o_sub_Send_Message": "Mesajını yaz. Bitirmek için boş satır + Enter yap.", + "o_Empty_Message": "Boş mesaj gönderilmedi.", + "o_AI_Response": "Gönderildi. AI yanıtı bekleniyor...", + "o_Drag_Drop": "Dosya yolu girin veya dosyayı sürükleyip bırakın", + "o_to_Menu": "Menüye dönmek için Enter...", + "o_Empty_Input": "Boş veri giremezsiniz.", + "o_Type_ESC": "İptal etmek için ilk satıra esc yazıp Enter'a bas.", + "o_Press_ESC": "ESC ile seçimi iptal edebilirsiniz.", + "o_Change_Chat": "Sohbeti Değiştir", + "o_Rename_Chat": "İsmi değişecek chat", + "o_New_Value": "Yeni değeri girin:", + "o_Chat_Rename_Fail": "Yeni isim mevcut isimden farklı olmalıdır.", + "o_AI_Model": "AI Modeli", + "o_Change_AI_Model": "AI Modelini Değiştir", + "o_Available_Languages": "Kullanılabilir diller", + "o_Language_Select_Hint": "Aşağıdaki dil kodlarından birini seçebilirsiniz.", + "o_Invalid_Language_Code": "Geçersiz dil kodu. Bu dil config içinde bulunamadı.", + "o_Saved": "Kaydedildi", + "o_Go_Back": "Geri Dön", + "o_Exit": "Çıkış", + "o_Selection": "Seçim", + "o_Error": "Hata", + "o_Timeout": "Zaman Aşımı", + "o_Failed": "Başarısız", + "o_Error_With_Detail": "❌ Hata:\n{error}", + "o_Config_Not_Found": "Config dosyası bulunamadı.", + "o_Config_Read_Error": "Config okunamadı: {error}", + "o_Chat_Read_Error": "Chat okunamadı: {error}", + "o_OpenRouter_Key_Missing": "OpenRouter API Key bulunamadı.", + "o_Invalid_Local_Model_Format": "Geçersiz local model formatı: {model}. Beklenen: provider/model", + "o_Local_Provider_Not_Found": "Local provider bulunamadı: {provider}", + "o_Local_Provider_Disabled": "Local provider devre dışı: {provider}", + "o_Unsupported_Local_Provider": "Desteklenmeyen local provider: {provider}", + "o_OpenRouter_API_Error": "OpenRouter API hatası ({status_code}): {details}", + "o_OpenRouter_Empty_Stream": "OpenRouter boş stream döndürdü.", + "o_Request_Timeout": "İstek zaman aşımına uğradı (timeout).", + "o_Internet_Connection_Error": "Bağlantı hatası. İnternet bağlantınızı kontrol edin.", + "o_Local_Connection_Error": "{provider} bağlantı hatası.\nLocal AI sunucusuna ulaşılamadı.\nBeklenen adres: {base_url}\n{provider} server çalışmıyor olabilir.", + "o_Ollama_API_Error": "Ollama API hatası ({status_code}): {details}", + "o_Ollama_JSON_Parse_Error": "Ollama JSON parse hatası: {error}", + "o_Ollama_Empty_Response": "Ollama boş cevap döndürdü.", + "o_Script_Not_Found": "Script bulunamadı: {path}", + "o_Photo_Capture_Error": "Fotoğraf çekme hatası: {error}", + "o_STT_Error": "STT hata: {error}", + "o_No_Speech_Detected": "Ses algılanamadı.", + "o_OpenRouter_STT_Key_Missing": "open_router_key yok", + "o_Whisper_Bin_Not_Found": "whisper_cpp_bin bulunamadı: {path}", + "o_Whisper_Model_Not_Found": "whisper_cpp_model bulunamadı: {path}", + "o_Whisper_Failed": "whisper.cpp failed", + "o_Desktop_Audio_Monitor_Not_Found": "Masaüstü sesi için monitor source bulunamadı.", + "o_Microphone_Source_Not_Found": "Mikrofon kaynağı bulunamadı.", + "o_Desktop_Audio_Requires_PWRecord": "Masaüstü sesi kaydı için pw-record gerekli.", + "o_Microphone_Device_Not_Found": "Mikrofon cihazı bulunamadı.", + "o_Audio_Recorder_Not_Found": "pw-record veya arecord bulunamadı.", + "o_Audio_Record_Start_Failed": "Ses kaydı başlatılamadı: {error}", + "o_Unknown_Error": "Bilinmeyen hata", + "o_Apply_Failed_Input": "❌ Uygulama başarısız: {error}", + "o_Apply_No_Ops": "Uygulanacak işlem yok", + "o_Apply_File_Not_Found": "Dosya bulunamadı: {path}", + "o_Apply_Find_Failed": "Bulma başarısız: {name} içinde referans bulunamadı", + "o_Apply_Unknown_Mode": "Bilinmeyen mod: {mode}", + "o_Apply_No_Changes": "Herhangi bir değişiklik yapılmadı (içerik aynı olabilir)", + "o_Apply_Exception": "Hata oluştu: {error}", + "o_Provider_Command_No_Command": "{provider}\n\nKomut ayarlanmamış.", + "o_Provider_Command_Timeout": "{provider}\n\nKomut zaman aşımına uğradı:\n{command}", + "o_Provider_Command_Error": "{provider}\n\n{error}", + "o_Provider_Command_Success": "{provider}\n\nKomut başarıyla çalıştırıldı.\n\n{command}", + "o_Provider_Command_Failed": "{provider}\n\nKomut başarısız oldu.\n\n{command}\n\nÇıkış kodu: {code}", + "o_Start_Local_Server_From_App": "{provider} uygulamasından local server başlatın.", + "o_Base_System_Prompt": "Bu mesaj bir talimat setidir.\nBu mesaja cevap verme.\nSadece kullanıcı mesajlarına cevap ver.\nSadece en son kullanıcı mesajını yanıtla.", + "o_Prompt_Copyable": "Bu konuşmada sana zaman zaman e-posta, kod, komut, yapılandırma dosyası veya birebir kopyalanması gereken metinler yazdırılabilir.\n\nFORMAT KURALI:\nEğer kullanıcıdan gelen istekte doğrudan kopyalanabilir bir içerik (örneğin kod, script, terminal komutu, yapılandırma dosyası vb.) üretmen gerekiyorsa aşağıdaki formatı kullan:\n\ntitle\n\ntitle\ncopy\n\ncopy\n\nKurallar:\n- 'title' ve 'copy' kelimeleri tek başına satır olmalıdır.\n- 'title' bloğu kod bloğundan önce gelmelidir.\n- Kod yoksa bu formatı kullanma.\n- Normal sohbet cevaplarında bu formatı kullanma.\n- Sadece gerçekten kopyalanabilir içerik varsa bu formatı kullan.", + "o_Prompt_Apply": "[FILE] BLOKLARI HAKKINDA:\n- Eğer kullanıcı bir dosya eklediyse, dosya içeriği bu mesaj içinde [FILE] ... [CONTENT] ... [/CONTENT] formatında ZATEN verilmiştir.\n- Bu durumda kullanıcıdan 'kodu buraya yapıştır' veya 'dosyayı paylaş' deme.\n- Değişiklik gerekiyorsa [CONTENT] içeriğini okuyarak işlem yap.\n- Dosya içeriğini tekrar isteme.\n- path alanı olarak [FILE] bloğunda verilen path değerini aynen kullan.\n\nDOSYA DEĞİŞİKLİĞİ KURALI:\n- [FILE] bloklarında 'editable: true' ise dosyada değişiklik önerebilirsin.\n- 'editable: false' olan dosyalar için ASLA dosyaya uygulanacak değişiklik isteme.\n- editable:false dosyalar için apply bloğu üretme.\n- Eğer değişiklik gerekmiyorsa apply bloğu hiç koyma.\n\nAPPLY (OTOMATİK UYGULAMA) PROTOKOLÜ:\nEğer ve sadece eğer kullanıcı 'editable: true' bir dosyada değişiklik istiyorsa,\ncevabının SONUNA aşağıdaki blok formatıyla ekle:\n\napply\n[\n {\n \"path\": \"/absolute/path/to/file\",\n \"find\": \"DOSYADA BİREBİR GEÇEN ANCHOR METİN\",\n \"mode\": \"before|after|replace\",\n \"text\": \"EKLENECEK/YERİNE GEÇECEK METİN\"\n }\n]\napply\n\nKurallar:\n- apply bloğunun içi SADECE JSON olmalı.\n- mode sadece: before, after, replace.\n- path MUTLAKA absolute path olmalı.\n- find, dosyada birebir bulunan kısa/orta bir anchor olmalı.\n\nAPPLY ÖZET SATIRI KURALI:\n- Eğer apply bloğu üretiyorsan, kesin/bitmiş ifade ASLA kullanma.\n- apply bloğundan ÖNCE sadece 1 satır özet yaz.", + "o_Prompt_File_Create": "[FILE] BLOKLARI HAKKINDA:\n- Önceki assistant mesajlarında [GENERATED_FILES] bloğu varsa, kullanıcı 'son dosya', 'oluşturduğun dosya' gibi ifadeler kullandığında bu path değerlerini kullan.\n- Kullanıcı tekrar dosya yüklemesini isteme.\n- Dosya içeriği [FILE] bloğunda verilmişse tekrar dosya isteme.\n\nFILE_CREATE PROTOKOLÜ:\nEğer kullanıcı editable:true olan DOCX, XLSX veya metin tabanlı bir dosyayı düzenleyip yeni dosya olarak almak istiyorsa, cevabının SONUNA şu formatı ekle:\n\nfile_create\n[\n {\n \"source_path\": \"/absolute/path/to/source.docx\",\n \"format\": \"docx\",\n \"output_name\": \"edited-file.docx\",\n \"content\": \"Yeni dosya içeriği\"\n }\n]\nfile_create\n\nKurallar:\n- JSON dışında açıklama koyma.\n- format sadece docx, xlsx, txt, md olabilir.\n- DOCX için content düz metin olarak verilir.\n- XLSX için content JSON tablo veya desteklenen işlem nesnesi olmalıdır.\n- Büyük XLSX dosyalarında satırları tek tek yazma.", + "o_Prompt_Web_Search": "Güncel veya bilmediğin bir bilgi gerekiyorsa normal cevap verme. Bunun yerine sadece şu JSON formatında cevap ver:\n{\"tool\":\"web_search\",\"query\":\"arama sorusu\"}\n\nKurallar:\n- Bu formatı sadece gerçekten güncel bilgi gerekiyorsa kullan.\n- Maç sonucu, güncel fiyat, son haber, yeni sürüm, bugünkü bilgi gibi konularda kullan.\n- Eğer web sonucu sana verilirse, sadece verilen sonuçlara göre kullanıcıya normal cevap ver.\n- Web arama sonuçları verildiğinde cevabın sonuna kısa bir 'Kaynaklar' bölümü ekle. Kaynak başlıklarını ve URL adreslerini sana verildiği şekilde kullan. Kaynak uydurma.\n- Web arama sonuçları kullanıldığında kaynak URL'lerini tam https:// linkleri olarak yaz.", + "o_Prompt_Structured": "Gerektiğinde cevabı düzenli, temiz ve parse edilebilir formatta ver. JSON istenirse sadece geçerli JSON üret.", + "o_Prompt_Code": "Kod yazarken doğrudan kullanılabilir, temiz ve gereksiz açıklamadan arındırılmış kod üret. Var olan kod düzenleniyorsa mevcut yapıyı mümkün olduğunca koru.", + "o_Prompt_PDF_Text_Only_To_DOCX_Rule": "PDF METİN DÖNÜŞTÜRME KURALI:\nPDF metni zaten [CONTENT] içinde verilmiştir.\nDosya yolu isteme.\nKullanıcıdan tekrar yükleme isteme.\n\nKurallar:\n- Görsel üretme.\n- HTML, markdown, image placeholder kullanma.\n- Sadece düz metin üret.\n- Metni isteğe göre düzenle ama anlamı koru.\n- Yeni içerik uydurma.\n\nSadece file_create formatında DOCX döndür:\nfile_create\n[\n {\n \"source_path\": \"/absolute/path/to/source.pdf\",\n \"format\": \"docx\",\n \"output_name\": \"edited-document.docx\",\n \"content\": \"Final metin\"\n }\n]\nfile_create", + "o_Prompt_PDF_Mixed_Text_Only_Rule": "PDF hem metin hem görsel içeriyor ancak sadece metin değiştirilecek.\nGörsellere dokunma.\n\nKurallar:\n- Sadece metinleri düzenle.\n- Yeni içerik uydurma.\n- Görselleri değiştirme.\n\nSadece JSON döndür:\n{\n \"type\": \"pdf_mixed_rewrite\",\n \"content\": \"File created.\",\n \"text_replacements\": {\n \"TEXT_BLOCK_ID\": \"new text\"\n },\n \"image_replacements\": {}\n}" + } diff --git a/memory.py b/memory.py new file mode 100644 index 0000000..36c9741 --- /dev/null +++ b/memory.py @@ -0,0 +1,252 @@ +import json +import re +import math +import hashlib +from pathlib import Path +from collections import Counter + + +def normalize_chat_data(raw): + if isinstance(raw, list): + return { + "summary": "", + "messages": raw, + "code_context": {}, + "memory_chunks": [] + } + + if not isinstance(raw, dict): + raw = {} + + raw.setdefault("summary", "") + raw.setdefault("messages", []) + raw.setdefault("code_context", {}) + raw.setdefault("memory_chunks", []) + + return raw + + +def load_chat_data(chat_file: Path) -> dict: + try: + raw = json.loads(chat_file.read_text(encoding="utf-8")) or [] + except Exception: + raw = [] + + return normalize_chat_data(raw) + + +def save_chat_data(chat_file: Path, chat_data: dict): + chat_file.write_text( + json.dumps(chat_data, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + +def get_chat_messages(chat_data: dict) -> list: + return chat_data.get("messages", []) + + +def add_memory_chunk(chat_data: dict, text: str, source: str = "chat"): + text = str(text or "").strip() + if not text: + return + + cid = hashlib.sha256((source + text).encode("utf-8")).hexdigest()[:16] + + for item in chat_data.get("memory_chunks", []): + if item.get("id") == cid: + return + + chat_data.setdefault("memory_chunks", []).append({ + "id": cid, + "source": source, + "text": text[:4000] + }) + + +def tokenize(text: str): + return re.findall(r"\w+", str(text).lower()) + + +def cosine_similarity(a: str, b: str) -> float: + va = Counter(tokenize(a)) + vb = Counter(tokenize(b)) + + if not va or not vb: + return 0.0 + + common = set(va) & set(vb) + dot = sum(va[w] * vb[w] for w in common) + + na = math.sqrt(sum(v * v for v in va.values())) + nb = math.sqrt(sum(v * v for v in vb.values())) + + if na == 0 or nb == 0: + return 0.0 + + return dot / (na * nb) + + +def retrieve_relevant_chunks(chat_data: dict, query: str, limit: int = 5) -> list: + scored = [] + + for chunk in chat_data.get("memory_chunks", []): + text = chunk.get("text", "") + score = cosine_similarity(query, text) + if score > 0: + scored.append((score, chunk)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [chunk for score, chunk in scored[:limit]] + + +def summarize_code_basic(file_path: Path) -> dict: + try: + code = file_path.read_text(encoding="utf-8", errors="replace") + except Exception: + return {} + + classes = [] + functions = [] + + for line in code.splitlines(): + s = line.strip() + if s.startswith("class "): + classes.append(s) + elif s.startswith("def "): + functions.append(s) + + return { + "path": str(file_path), + "name": file_path.name, + "summary": f"{file_path.name}: {len(classes)} class, {len(functions)} function bulundu.", + "classes": classes[:30], + "functions": functions[:80] + } + + +def update_code_context_from_message(chat_data: dict, msg: dict): + files = msg.get("files") or [] + if not isinstance(files, list): + return + + for f in files: + if not isinstance(f, dict): + continue + + p_raw = str(f.get("path") or "").strip() + if not p_raw: + continue + + p = Path(p_raw) + if not p.exists() or not p.is_file(): + continue + + if p.suffix.lower() not in { + ".py", ".js", ".ts", ".tsx", ".jsx", ".json", ".html", + ".css", ".cpp", ".c", ".h", ".hpp", ".java", ".go", + ".rs", ".sh", ".md", ".txt", ".yaml", ".yml" + }: + continue + + ctx = summarize_code_basic(p) + if not ctx: + continue + + chat_data.setdefault("code_context", {})[str(p)] = ctx + + add_memory_chunk( + chat_data, + ctx["summary"] + "\nFunctions:\n" + "\n".join(ctx.get("functions", [])[:30]), + source=f"code:{p.name}" + ) + + +def build_memory_context(chat_data: dict, user_message: str) -> str: + parts = [] + + summary = str(chat_data.get("summary") or "").strip() + if summary: + parts.append("Conversation summary:\n" + summary) + + relevant = retrieve_relevant_chunks(chat_data, user_message, limit=5) + if relevant: + txt = "Relevant old context:\n" + for ch in relevant: + txt += f"- [{ch.get('source')}] {ch.get('text')}\n" + parts.append(txt) + + code_context = chat_data.get("code_context") or {} + if isinstance(code_context, dict) and code_context: + txt = "Known code context:\n" + for path, ctx in code_context.items(): + txt += f"\nFile: {ctx.get('name') or path}\n" + txt += f"{ctx.get('summary', '')}\n" + + funcs = ctx.get("functions") or [] + if funcs: + txt += "Functions:\n" + "\n".join(funcs[:30]) + "\n" + + classes = ctx.get("classes") or [] + if classes: + txt += "Classes:\n" + "\n".join(classes[:20]) + "\n" + + parts.append(txt) + + return "\n\n".join(parts).strip() + + +def should_update_summary(chat_data: dict, every: int = 20) -> bool: + messages = chat_data.get("messages", []) + real = [m for m in messages if isinstance(m, dict) and m.get("role") in ("user", "bot", "assistant")] + return len(real) > 0 and len(real) % every == 0 + +def make_simple_summary(chat_data: dict) -> str: + messages = chat_data.get("messages", []) + + real_messages = [ + m for m in messages + if isinstance(m, dict) and m.get("role") in ("user", "bot", "assistant") + ] + + last_items = real_messages[-20:] + + parts = [] + + for m in last_items: + role = str(m.get("role") or "").strip() + text = str(m.get("content") or "").strip() + + if not text: + continue + + text = text.replace("\n", " ") + if len(text) > 300: + text = text[:300] + "..." + + parts.append(f"{role}: {text}") + + code_context = chat_data.get("code_context") or {} + known_files = [] + + if isinstance(code_context, dict): + for _path, ctx in code_context.items(): + if not isinstance(ctx, dict): + continue + + name = str(ctx.get("name") or "").strip() + summary = str(ctx.get("summary") or "").strip() + + if name: + known_files.append(name) + + if summary: + parts.append(f"code_context: {summary}") + + summary = "Recent conversation summary:\n" + summary += "\n".join(parts) + + if known_files: + summary += "\n\nKnown code files: " + ", ".join(known_files) + + return summary[:6000] diff --git a/ui.py b/ui.py index 2645dda..de576d5 100644 --- a/ui.py +++ b/ui.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import gi +import gc import os import sys import json @@ -15,7 +16,7 @@ import shutil gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") -from gi.repository import Gtk, GLib, Gio, Gdk, Pango, Adw +from gi.repository import Gtk, GLib, Gio, Gdk, Pango, Adw, GdkPixbuf Adw.init() CONFIG_PATH = Path.home() / ".config" / "capture-ai" / "config.json" @@ -23,26 +24,15 @@ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) BASE_DIR = Path.home() / ".cache" / "capture-ai" CHAT_DIR = BASE_DIR / "chats" +LANG_DIR = Path.home() / "capture-ai" / "language" AI_SCRIPT = str(Path.home() / "capture-ai/ai.py") GENERATED_DIR = BASE_DIR / "generated_images" GENERATED_DIR.mkdir(parents=True, exist_ok=True) +GENERATED_FILES_DIR = BASE_DIR / "generated_files" +GENERATED_FILES_DIR.mkdir(parents=True, exist_ok=True) CHAT_DIR.mkdir(parents=True, exist_ok=True) -# Config dosyasından son açılan chat'i al, yoksa default.json kullan -if CONFIG_PATH.exists(): - with open(CONFIG_PATH, "r", encoding="utf-8") as f: - config = json.load(f) or {} - last_chat_name = config.get("last_chat", "default.json") - dark_mode = config.get("dark_mode", True) -else: - config = {} - last_chat_name = "default.json" - dark_mode = True - -DEFAULT_CHAT = CHAT_DIR / last_chat_name -if not DEFAULT_CHAT.exists(): - DEFAULT_CHAT.write_text("[]", encoding="utf-8") DEFAULT_CUSTOM_COLORS_DARK = { "window_bg": "#111111", @@ -52,6 +42,7 @@ DEFAULT_CUSTOM_COLORS_DARK = { "bot_bg": "#222222", "bot_text": "#ffffff", "sidebar_text": "#dddddd", + "local_model_highlight": "#ff9800", } DEFAULT_CUSTOM_COLORS_LIGHT = { @@ -62,8 +53,367 @@ DEFAULT_CUSTOM_COLORS_LIGHT = { "bot_bg": "#ececec", "bot_text": "#111111", "sidebar_text": "#111111", + "local_model_highlight": "#ff9800", } + +def ensure_bool_strict(value, default): + if isinstance(value, bool): + return value + + if isinstance(value, str): + v = value.strip().lower() + if v in ("true", "1", "yes", "on"): + return True + if v in ("false", "0", "no", "off"): + return False + + return default + + +def ensure_base_config(config: dict) -> dict: + defaults = { + "ui_language": "en", + "last_chat": "default.json", + "dark_mode": True, + "open_router_key": "", + "tavily_api_key": "", + "ask_for_web_search": True, + "prompt_chooser_blocks": ["copyable"], + "response_style": "normal", + "show_usage": True, + "show_token_value": False, + "token_value": "2.0", + "force_ui_language": False, + "is_mic_online": True, + "use_desktop_voice": False, + "stt_model_online": "openai/gpt-audio-mini", + "whisper_cpp_bin": "", + "whisper_cpp_model": "", + "pinned_chats": [], + "custom_colors_dark": dict(DEFAULT_CUSTOM_COLORS_DARK), + "custom_colors_light": dict(DEFAULT_CUSTOM_COLORS_LIGHT), + "chat_bg_image": "", + "rag_settings": { + "recent_message_count": 10, + "retrieved_chunk_count": 5, + "summary_update_every": 20, + "memory_chunk_max_chars": 1200, + "summary_max_chars": 6000, + "code_context_max_chars": 8000, + "use_summary": True, + "use_recent_messages": True, + "use_retrieval": True, + "use_code_context": True, + "include_recent_attachments": False + }, + "chat_rag_switch": { + "default.json": True + }, + "ai_models": [{ + "id": "google/gemini-2.5-flash-image", + "local": False + }], + "chat_models": { + "default.json": { + "id": "google/gemini-2.5-flash-image", + "local": False + } + }, + "local_providers": { + "ollama": { + "enabled": False, + "base_url": "http://127.0.0.1:11434", + "run_startup": "ollama serve", + "stop_command": "pkill -f 'ollama serve'", + "system_error": "Ollama bağlantı hatası.\nBeklenen adres: {base_url}\n'ollama serve' çalıştır.", + "temperature": "", + "top_p": "", + "top_k": "", + "repeat_penalty": "", + "num_ctx": "", + "num_predict": "", + "keep_alive": "", + "system_prompt": "" + } + }, + } + + bool_keys = [ + "dark_mode", + "show_usage", + "show_token_value", + "force_ui_language", + "is_mic_online", + "use_desktop_voice", + ] + + changed = False + + for key, default in defaults.items(): + if key not in config: + config[key] = default + changed = True + continue + + value = config.get(key) + + if key in bool_keys: + fixed = ensure_bool_strict(value, default) + if fixed != value: + config[key] = fixed + changed = True + continue + + if isinstance(default, str): + if not isinstance(value, str) or not value.strip(): + config[key] = default + changed = True + continue + + if isinstance(default, list): + if not isinstance(value, list): + config[key] = default + changed = True + continue + + if isinstance(default, dict): + if not isinstance(value, dict): + config[key] = default + changed = True + continue + + # ai_models normalize + if isinstance(config["ai_models"], list): + cleaned_models = [] + for item in config["ai_models"]: + if isinstance(item, dict): + mid = str(item.get("id") or "").strip() + if not mid: + continue + cleaned_models.append({ + "id": mid, + "local": ensure_bool_strict(item.get("local"), False) + }) + elif isinstance(item, str): + mid = item.strip() + if not mid: + continue + cleaned_models.append({ + "id": mid, + "local": False + }) + + if not cleaned_models: + cleaned_models = [dict(defaults["ai_models"][0])] + + if cleaned_models != config["ai_models"]: + config["ai_models"] = cleaned_models + changed = True + + # chat_models normalize + if isinstance(config["chat_models"], dict): + fixed_chat_models = {} + + for chat_name, model in config["chat_models"].items(): + chat_key = str(chat_name or "").strip() + if not chat_key: + continue + + if isinstance(model, dict): + mid = str(model.get("id") or "").strip() + if not mid: + mid = defaults["chat_models"]["default.json"]["id"] + + fixed_chat_models[chat_key] = { + "id": mid, + "local": ensure_bool_strict(model.get("local"), False) + } + + elif isinstance(model, str): + mid = model.strip() + if not mid: + mid = defaults["chat_models"]["default.json"]["id"] + + fixed_chat_models[chat_key] = { + "id": mid, + "local": False + } + + if not fixed_chat_models: + fixed_chat_models = dict(defaults["chat_models"]) + + if fixed_chat_models != config["chat_models"]: + config["chat_models"] = fixed_chat_models + changed = True + + # rag_settings normalize + rag_defaults = defaults["rag_settings"] + + if not isinstance(config.get("rag_settings"), dict): + config["rag_settings"] = dict(rag_defaults) + changed = True + else: + fixed_rag = dict(rag_defaults) + raw_rag = config.get("rag_settings", {}) + + int_keys = [ + "recent_message_count", + "retrieved_chunk_count", + "summary_update_every", + "memory_chunk_max_chars", + "summary_max_chars", + "code_context_max_chars", + ] + + bool_rag_keys = [ + "use_summary", + "use_recent_messages", + "use_retrieval", + "use_code_context", + "include_recent_attachments", + ] + + for key in int_keys: + try: + val = int(raw_rag.get(key, rag_defaults[key])) + except Exception: + val = rag_defaults[key] + + if key in ("recent_message_count", "retrieved_chunk_count"): + val = max(0, min(val, 50)) + elif key == "summary_update_every": + val = max(1, min(val, 200)) + else: + val = max(100, min(val, 100_000)) + + fixed_rag[key] = val + + for key in bool_rag_keys: + fixed_rag[key] = ensure_bool_strict( + raw_rag.get(key, rag_defaults[key]), + rag_defaults[key] + ) + + if fixed_rag != config.get("rag_settings"): + config["rag_settings"] = fixed_rag + changed = True + + + # local_providers normalize + provider_defaults = { + "enabled": False, + "base_url": "http://127.0.0.1:11434", + "run_startup": "", + "stop_command": "", + "system_error": "", + "temperature": "", + "top_p": "", + "top_k": "", + "repeat_penalty": "", + "num_ctx": "", + "num_predict": "", + "keep_alive": "", + "system_prompt": "" + } + + if not isinstance(config.get("local_providers"), dict): + config["local_providers"] = { + "ollama": dict(provider_defaults) + } + changed = True + else: + fixed_local_providers = {} + + for provider_name, provider_cfg in config["local_providers"].items(): + pname = str(provider_name or "").strip() + if not pname: + continue + + if not isinstance(provider_cfg, dict): + provider_cfg = {} + + fixed = dict(provider_defaults) + + fixed["enabled"] = ensure_bool_strict( + provider_cfg.get("enabled"), + provider_defaults["enabled"] + ) + + for key in [ + "base_url", + "run_startup", + "stop_command", + "system_error", + "temperature", + "top_p", + "top_k", + "repeat_penalty", + "num_ctx", + "num_predict", + "keep_alive", + "system_prompt", + ]: + val = provider_cfg.get(key, provider_defaults[key]) + if not isinstance(val, str): + val = str(provider_defaults[key]) + if key == "base_url" and not val.strip(): + val = provider_defaults["base_url"] + fixed[key] = val + + fixed_local_providers[pname] = fixed + + if not fixed_local_providers: + fixed_local_providers = { + "ollama": dict(provider_defaults) + } + + if fixed_local_providers != config.get("local_providers"): + config["local_providers"] = fixed_local_providers + changed = True + + return config + +# Config dosyasından son açılan chat'i al, yoksa default.json kullan +if CONFIG_PATH.exists(): + try: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + config = json.load(f) or {} + except json.JSONDecodeError: + try: + bad_path = CONFIG_PATH.with_suffix(".broken.json") + shutil.copy2(CONFIG_PATH, bad_path) + except Exception: + pass + config = {} + + old_config = json.loads(json.dumps(config)) + config = ensure_base_config(config) + + if config != old_config: + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) +else: + config = ensure_base_config({}) + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + +last_chat_name = config["last_chat"] +dark_mode = config["dark_mode"] + +DEFAULT_CHAT = CHAT_DIR / last_chat_name +if not DEFAULT_CHAT.exists(): + DEFAULT_CHAT.write_text( + json.dumps({ + "summary": "", + "messages": [], + "code_context": {}, + "memory_chunks": [] + }, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + def fix_mojibake(s: str) -> str: if not s: return s @@ -82,18 +432,26 @@ class ChatApp(Gtk.Application): super().__init__() self.connect("activate", self.on_activate) + self.loaded_message_start = None + self.lazy_chat_initial_count = 10 + self.lazy_chat_batch_count = 30 + self._loading_older_messages = False + self.current_chat = DEFAULT_CHAT self.image_path = image_path self.pending_images = [] self.pending_files = [] + self._lang_cache = None + self._lang_cache_lang = None + if image_path: try: from urllib.parse import urlparse, unquote p = str(image_path).strip() - # file://... URI gelirse path’e çevir + # file://... uri gelirse path’e çevir if p.startswith("file://"): p = unquote(urlparse(p).path) @@ -111,6 +469,12 @@ class ChatApp(Gtk.Application): self.ai_stop_requested = False self.is_generating = False + self.ai_procs = {} + self.ai_stop_requested_chats = set() + self.generating_chats = set() + self.stream_ui_ready_chats = set() + self.stream_active_chats = set() + self.auto_focus_enabled = True self.auto_scroll_enabled = True self.selected_indexes = set() @@ -121,8 +485,60 @@ class ChatApp(Gtk.Application): self.active_model = None self.streaming_row = None - # ---------------- CONFIG HELPERS ---------------- + # ----------------- OPTIMIZATION ----------------- + def clear_closed_chat_memory(self): + try: + self.streaming_row = None + self.streaming_label = None + self._streaming_bot_idx = None + try: + self.typing_row.set_visible(False) + except Exception: + pass + + while True: + child = self.chat_box.get_first_child() + if not child: + break + self.chat_box.remove(child) + + try: + self.selection_label.set_text("") + self.selection_row.set_opacity(0.0) + self.selection_row.set_sensitive(False) + except Exception: + pass + + try: + self.pending_images.clear() + self.pending_files.clear() + except Exception: + pass + + try: + while True: + c = self.preview_hbox.get_first_child() + if not c: + break + self.preview_hbox.remove(c) + self.preview_row.set_visible(False) + except Exception: + pass + + try: + buf = self.textview.get_buffer() + buf.set_text("") + except Exception: + pass + + gc.collect() + return False + + except Exception: + return False + + # ---------------- CONFIG HELPERS ---------------- def load_config(self): try: if CONFIG_PATH.exists(): @@ -140,46 +556,237 @@ class ChatApp(Gtk.Application): except Exception as e: print("config save error:", e) + def load_chat_data_ui(self, path=None): + path = Path(path or self.current_chat) + + try: + raw = json.loads(path.read_text(encoding="utf-8")) or [] + except Exception: + raw = [] + + # Eski format: direkt liste + if isinstance(raw, list): + return { + "summary": "", + "messages": raw, + "code_context": {}, + "memory_chunks": [] + } + + # Yeni format: dict + if not isinstance(raw, dict): + raw = {} + + raw.setdefault("summary", "") + raw.setdefault("messages", []) + raw.setdefault("code_context", {}) + raw.setdefault("memory_chunks", []) + + if not isinstance(raw["messages"], list): + raw["messages"] = [] + + return raw - def ensure_ui_i18n_config(self): - cfg = self.load_config() - changed = False + def save_chat_data_ui(self, data, path=None): + path = Path(path or self.current_chat) - if not isinstance(cfg.get("ui_language"), str) or not str(cfg.get("ui_language")).strip(): - cfg["ui_language"] = "tr" - changed = True + if not isinstance(data, dict): + data = { + "summary": "", + "messages": [], + "code_context": {}, + "memory_chunks": [] + } - if not isinstance(cfg.get("ui_strings"), dict): - cfg["ui_strings"] = {} - changed = True + data.setdefault("summary", "") + data.setdefault("messages", []) + data.setdefault("code_context", {}) + data.setdefault("memory_chunks", []) - if changed: - self.save_config(cfg) + path.write_text( + json.dumps(data, ensure_ascii=False, indent=2), + encoding="utf-8" + ) - def get_ui_language(self) -> str: - cfg = self.load_config() - lang = str(cfg.get("ui_language", "tr") or "tr").strip().lower() - return lang if lang else "tr" + + def get_context_mode(self, chat_path=None) -> str: + chat_path = Path(chat_path or self.current_chat) + + cfg = ensure_base_config(self.load_config()) + chat_rag_switch = cfg.setdefault("chat_rag_switch", {}) + + direct_mode = ensure_bool_strict( + chat_rag_switch.get(chat_path.name, True), + True + ) + + return "direct" if direct_mode else "rag" + + def set_context_mode(self, mode: str, chat_path=None): + mode = str(mode or "").strip().lower() + if mode not in ("rag", "direct"): + mode = "direct" + + chat_path = Path(chat_path or self.current_chat) + + cfg = ensure_base_config(self.load_config()) + cfg.setdefault("chat_rag_switch", {}) + + cfg["chat_rag_switch"][chat_path.name] = (mode == "direct") + + self.save_config(cfg) + self.refresh_context_mode_switch() def get_ui_strings_map(self) -> dict: - cfg = self.load_config() - raw = cfg.get("ui_strings", {}) - return raw if isinstance(raw, dict) else {} + current_lang = self.get_ui_language() + + if ( + self._lang_cache is not None and + self._lang_cache_lang == current_lang + ): + return self._lang_cache + + lang_path = LANG_DIR / f"{current_lang}.json" + fallback_path = LANG_DIR / "en.json" + + data = {} + + try: + if lang_path.exists(): + raw = json.loads(lang_path.read_text(encoding="utf-8")) or {} + if isinstance(raw, dict): + data = raw + except Exception as e: + print(f"language file read error ({lang_path.name}):", e) + + if not data: + try: + if fallback_path.exists(): + raw = json.loads(fallback_path.read_text(encoding="utf-8")) or {} + if isinstance(raw, dict): + data = raw + except Exception as e: + print(f"fallback language file read error ({fallback_path.name}):", e) + + if not isinstance(data, dict): + data = {} + + self._lang_cache = data + self._lang_cache_lang = current_lang + return self._lang_cache def get_ui_lang_map(self, lang: str | None = None) -> dict: - if lang is None: - lang = self.get_ui_language() + if lang is None or lang == self.get_ui_language(): + return self.get_ui_strings_map() - all_strings = self.get_ui_strings_map() - selected = all_strings.get(lang, {}) - if not isinstance(selected, dict): - selected = {} - return selected + # farklı bir dil özellikle istenirse bir kerelik oku + lang = str(lang or "").strip().lower() or "en" + lang_path = LANG_DIR / f"{lang}.json" + fallback_path = LANG_DIR / "en.json" - def __call__(self, key: str) -> str: + data = {} + + try: + if lang_path.exists(): + raw = json.loads(lang_path.read_text(encoding="utf-8")) or {} + if isinstance(raw, dict): + data = raw + except Exception as e: + print(f"language file read error ({lang_path.name}):", e) + + if not data: + try: + if fallback_path.exists(): + raw = json.loads(fallback_path.read_text(encoding="utf-8")) or {} + if isinstance(raw, dict): + data = raw + except Exception as e: + print(f"fallback language file read error ({fallback_path.name}):", e) + + return data if isinstance(data, dict) else {} + + def __call__(self, key: str, **kwargs) -> str: lang_map = self.get_ui_lang_map() - return str(lang_map.get(key, key)) + text = str(lang_map.get(key, key)) + + try: + return text.format(**kwargs) + except Exception: + return text + + def render_message_text(self, msg: dict) -> str: + if not isinstance(msg, dict): + return "" + + if msg.get("error_key"): + params = msg.get("error_params") or {} + if not isinstance(params, dict): + params = {} + + error_key = str(msg.get("error_key")) + + # Zaten genel error wrapper ise tekrar "Error:" ekleme + if error_key == "o_Error_With_Detail": + return self(error_key, **params) + + inner = self(error_key, **params) + return self("o_Error_With_Detail", error=inner) + + if msg.get("status_key"): + params = msg.get("status_params") or {} + if not isinstance(params, dict): + params = {} + return self(str(msg.get("status_key")), **params) + + if msg.get("i18n_key"): + params = msg.get("i18n_params") or {} + if not isinstance(params, dict): + params = {} + return self(str(msg.get("i18n_key")), **params) + + return str(msg.get("content") or "") + + def append_text_with_links(self, parent, text: str): + import re + + text = str(text or "") + url_re = re.compile(r"(https?://[^\s\]\)\"'>]+)") + + parts = url_re.split(text) + + for part in parts: + if not part: + continue + + if url_re.fullmatch(part): + clean_url = part.rstrip(".,;:") + trailing = part[len(clean_url):] + + link = Gtk.LinkButton.new_with_label(clean_url, clean_url) + link.set_halign(Gtk.Align.START) + link.set_uri(clean_url) + self._consume_click(link) + parent.append(link) + + if trailing: + lab = Gtk.Label(label=trailing) + lab.set_xalign(0) + parent.append(lab) + + else: + lab = Gtk.Label(label=part) + lab.set_xalign(0) + lab.set_wrap(True) + lab.set_wrap_mode(Pango.WrapMode.WORD_CHAR) + lab.set_halign(Gtk.Align.START) + parent.append(lab) + + + def get_ui_language(self) -> str: + cfg = ensure_base_config(self.load_config()) + lang = str(cfg["ui_language"]).strip().lower() + return lang if lang else "en" def set_ui_language(self, lang: str): lang = str(lang or "").strip().lower() @@ -190,43 +797,44 @@ class ChatApp(Gtk.Application): cfg["ui_language"] = lang self.save_config(cfg) + self._lang_cache = None + self._lang_cache_lang = None + def get_available_ui_languages(self) -> list[str]: - strings = self.get_ui_strings_map() - if not isinstance(strings, dict): - return [] - out = [] - for k in strings.keys(): - ks = str(k).strip() - if ks: - out.append(ks) - return out + try: + if LANG_DIR.exists(): + for p in LANG_DIR.glob("*.json"): + name = p.stem.strip().lower() + if name: + out.append(name) + except Exception as e: + print("language directory read error:", e) + + return sorted(set(out)) def _get_active_colors_key(self) -> str: - cfg = self.load_config() - is_dark = bool(cfg.get("dark_mode", True)) + cfg = ensure_base_config(self.load_config()) + is_dark = cfg["dark_mode"] return "custom_colors_dark" if is_dark else "custom_colors_light" def _get_default_theme_colors(self, is_dark: bool | None = None) -> dict: if is_dark is None: - cfg = self.load_config() - is_dark = bool(cfg.get("dark_mode", True)) + cfg = ensure_base_config(self.load_config()) + is_dark = cfg["dark_mode"] base = DEFAULT_CUSTOM_COLORS_DARK if is_dark else DEFAULT_CUSTOM_COLORS_LIGHT return dict(base) def _get_theme_colors(self) -> dict: - cfg = self.load_config() - is_dark = bool(cfg.get("dark_mode", True)) + cfg = ensure_base_config(self.load_config()) + is_dark = cfg["dark_mode"] key = "custom_colors_dark" if is_dark else "custom_colors_light" - raw = cfg.get(key, {}) - if not isinstance(raw, dict): - raw = {} - + raw = cfg[key] defaults = self._get_default_theme_colors(is_dark) out = {} @@ -241,96 +849,161 @@ class ChatApp(Gtk.Application): self.save_config(cfg) def _reset_active_theme_colors_to_default(self): - cfg = self.load_config() - is_dark = bool(cfg.get("dark_mode", True)) + cfg = ensure_base_config(self.load_config()) + is_dark = cfg["dark_mode"] key = "custom_colors_dark" if is_dark else "custom_colors_light" cfg[key] = self._get_default_theme_colors(is_dark) self.save_config(cfg) - def _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 _normalize_model_entry(self, item) -> dict | None: + if isinstance(item, str): + mid = item.strip() + if not mid: + return None + return { + "id": mid, + "local": False + } + + if not isinstance(item, dict): + return None + + mid = str(item.get("id", "")).strip() + if not mid: + return None + + return { + "id": mid, + "local": bool(item.get("local", False)) + } + + def _get_models_list(self, cfg: dict) -> list[dict]: + raw = cfg.get("ai_models", []) + if not isinstance(raw, list): + raw = [] + + out = [] + seen = set() + + for item in raw: + norm = self._normalize_model_entry(item) + if not norm: + continue + + mid = norm["id"] + if mid in seen: + continue + + seen.add(mid) + out.append(norm) + + return out + + def _get_model_ids(self, cfg: dict) -> list[str]: + return [m["id"] for m in self._get_models_list(cfg)] + + def _find_model_entry(self, cfg: dict, model_id: str) -> dict | None: + model_id = str(model_id or "").strip() + if not model_id: + return None + + for item in self._get_models_list(cfg): + if item["id"] == model_id: + return item + + return None def _ensure_min_one_model(self, cfg: dict): - """ - 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"] + models = [{ + "id": "google/gemini-2.5-flash-image", + "local": False + }] cfg["ai_models"] = models self.save_config(cfg) return models def get_default_model(self, cfg: dict) -> str: models = self._ensure_min_one_model(cfg) - return str(models[0]) + return str(models[0]["id"]) 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() + cfg = ensure_base_config(self.load_config()) models = self._ensure_min_one_model(cfg) + model_ids = [m["id"] for m in models] - chat_models = cfg.get("chat_models", {}) - if not isinstance(chat_models, dict): - chat_models = {} - + chat_models = cfg["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 + selected = chat_models.get(key) + model_id = "" + + if isinstance(selected, dict): + model_id = str(selected.get("id") or "").strip() + elif isinstance(selected, str): + model_id = selected.strip() + + if (not model_id) or (model_id not in model_ids): + model_id = str(models[0]["id"]) + chat_models[key] = { + "id": model_id, + "local": bool(models[0].get("local", False)) + } cfg["chat_models"] = chat_models self.save_config(cfg) - return m + return model_id - 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) - """ + def set_chat_model(self, chat_path: Path, model_id: str, is_local: bool | None = None): model_id = str(model_id).strip() if not model_id: return - cfg = self.load_config() + cfg = ensure_base_config(self.load_config()) + cfg.setdefault("chat_models", {}) 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) + found = None + rest = [] - cfg["ai_models"] = models + for item in models: + if item["id"] == model_id and found is None: + # mevcut model → local bilgisini koru + current_local = bool(item.get("local", False)) - 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 + found = { + "id": model_id, + "local": current_local if is_local is None else bool(is_local) + } + else: + rest.append(item) - # selected_model artık gereksiz; istersen yazma: - # cfg["selected_model"] = model_id + # model hiç yoksa → yeni ekle + if found is None: + found = { + "id": model_id, + "local": bool(is_local) if is_local is not None else False + } + + # LRU: en üste al + cfg["ai_models"] = [found] + rest + + # chat mapping + cfg["chat_models"][chat_path.name] = { + "id": found["id"], + "local": bool(found.get("local", False)) + } self.save_config(cfg) - # UI state - self.active_model = model_id + # UI refresh + self.active_model = { + "id": found["id"], + "local": bool(found.get("local", False)) + } self.load_models_list() self.apply_models_visibility() @@ -340,15 +1013,13 @@ class ChatApp(Gtk.Application): self.win = Gtk.ApplicationWindow(application=app) self.win.set_title("Capture AI") self.win.set_default_size(1100, 750) - self.ensure_color_configs() - self.ensure_ui_i18n_config() - self.ensure_audio_input_config() self.apply_theme(dark_mode) self.apply_css() root = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) root.add_css_class("app-root") self.win.set_child(root) + self.setup_file_drop_target(root) # SIDEBAR self.sidebar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) @@ -385,6 +1056,40 @@ class ChatApp(Gtk.Application): self.sidebar_scroll.set_child(self.sidebar_scroll_content) self.sidebar_scroll_content.set_valign(Gtk.Align.START) + # ---------------- CONTEXT MODE SWITCH ---------------- + self.context_mode_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self.context_mode_row.set_halign(Gtk.Align.CENTER) + self.context_mode_row.set_hexpand(True) + self.context_mode_row.set_margin_top(4) + self.context_mode_row.set_margin_bottom(4) + + self.context_rag_label = Gtk.Label() + self.bind_i18n(self.context_rag_label, "label", "o_Context_RAG") + self.context_rag_label.set_xalign(0) + self.context_rag_label.set_halign(Gtk.Align.START) + + self.context_mode_switch = Gtk.Switch() + self.context_mode_switch.set_halign(Gtk.Align.CENTER) + self.context_mode_switch.set_valign(Gtk.Align.CENTER) + + + self.context_direct_label = Gtk.Label() + self.bind_i18n(self.context_direct_label, "label", "o_Context_Direct") + self.context_direct_label.set_xalign(0) + self.context_direct_label.set_halign(Gtk.Align.START) + + self.context_mode_row.append(self.context_rag_label) + self.context_mode_row.append(self.context_mode_switch) + self.context_mode_row.append(self.context_direct_label) + + self.bind_i18n(self.context_mode_row, "tooltip", "o_Context_Mode") + + + self.context_mode_switch.connect("notify::active", self.on_context_mode_switch_changed) + + self.sidebar_scroll_content.append(self.context_mode_row) + self.refresh_context_mode_switch() + # AI MODELS HEADER + LIST self.models_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) self.models_header.set_hexpand(True) @@ -464,6 +1169,7 @@ class ChatApp(Gtk.Application): overlay.set_hexpand(True) overlay.set_vexpand(True) main.append(overlay) + self.setup_file_drop_target(overlay) # Scroll content self.content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) @@ -577,7 +1283,7 @@ class ChatApp(Gtk.Application): self.preview_row.set_visible(False) self.bottom_spacer = Gtk.Box() - self.bottom_spacer.set_size_request(-1, 110) + self.bottom_spacer.set_size_request(-1, 80) self.content_box.append(self.bottom_spacer) self.scroll = Gtk.ScrolledWindow() @@ -600,6 +1306,8 @@ class ChatApp(Gtk.Application): bottom_bar.set_vexpand(False) overlay.add_overlay(bottom_bar) + self.setup_file_drop_target(bottom_bar) + bottom_clamp = Adw.Clamp() bottom_clamp.set_hexpand(True) bottom_clamp.set_halign(Gtk.Align.FILL) @@ -672,6 +1380,8 @@ class ChatApp(Gtk.Application): input_card.add_css_class("chat-input-box") input_wrapper.append(input_card) + self.input_card = input_card + # --- TOP: TEXT AREA --- self.textview = Gtk.TextView() self.textview.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) @@ -679,11 +1389,15 @@ class ChatApp(Gtk.Application): self.textview.set_vexpand(False) self.textview.set_size_request(260, -1) self.textview.add_css_class("chat-textview") + self.setup_file_drop_target(self.textview) self.textview.set_top_margin(4) self.textview.set_bottom_margin(4) self.textview.set_left_margin(2) self.textview.set_right_margin(2) + buf = self.textview.get_buffer() + buf.connect("changed", self.on_input_buffer_changed) + self.text_scroll = Gtk.ScrolledWindow() self.text_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) self.text_scroll.set_propagate_natural_height(True) @@ -714,6 +1428,15 @@ class ChatApp(Gtk.Application): self.attach_btn = attach_btn + prompt_btn = Gtk.Button(label="🖍️") + prompt_btn.set_valign(Gtk.Align.CENTER) + self.bind_i18n(prompt_btn, "tooltip", "o_Prompt_Chooser") + prompt_btn.add_css_class("flat") + prompt_btn.connect("clicked", self.open_prompt_chooser_menu) + controls_row.append(prompt_btn) + + self.prompt_btn = prompt_btn + # orta boşluk controls_spacer = Gtk.Box() controls_spacer.set_hexpand(True) @@ -750,7 +1473,6 @@ class ChatApp(Gtk.Application): self.scroll_btn.set_size_request(36, 36) self.scroll_btn.set_valign(Gtk.Align.END) self.scroll_btn.set_halign(Gtk.Align.END) - self.scroll_btn.set_margin_bottom(150) # preview+input üstünde dursun self.scroll_btn.set_margin_end(20) self.scroll_btn.add_css_class("scroll-btn") self.bind_i18n(self.scroll_btn, "tooltip", "o_Scroll_Down") @@ -768,8 +1490,14 @@ class ChatApp(Gtk.Application): self.load_models_list() self.apply_models_visibility() + self.setup_file_drop_target(input_card) + self.setup_file_drop_target(self.text_scroll) + self.setup_file_drop_target(self.textview) + self.setup_file_drop_target(self.content_box) + self.setup_file_drop_target(self.scroll) self.win.present() GLib.idle_add(self.force_focus_entry) + GLib.idle_add(self._update_bottom_spacer_height) # ---------------- CSS / THEME ---------------- @@ -783,6 +1511,7 @@ class ChatApp(Gtk.Application): bot_bg = colors["bot_bg"] bot_text = colors["bot_text"] sidebar_text = colors["sidebar_text"] + local_model_highlight = colors["local_model_highlight"] bg_path = self._get_chat_bg_image_path().strip() bg_css = "" @@ -895,10 +1624,14 @@ class ChatApp(Gtk.Application): padding: 8px; }} + .model-local-label {{ + color: {local_model_highlight}; + }} + .user-bubble {{ background: {user_bg}; color: {user_text}; - border-radius: 12px; + border-radius: 12px 5px 12px 12px; padding: 10px; margin-right: 10px; }} @@ -906,11 +1639,25 @@ class ChatApp(Gtk.Application): .bot-bubble {{ background: {bot_bg}; color: {bot_text}; - border-radius: 12px; + border-radius: 5px 12px 12px; padding: 10px; margin-left: 10px; }} + .suggestion-wrap {{ + margin-top: 120px; + }} + + .suggestion-bubble {{ + padding: 10px 14px; + border-radius: 999px; + background: rgba(255,255,255,0.08); + }} + + window.light .suggestion-bubble {{ + background: rgba(0,0,0,0.06); + }} + .image-zoom-btn {{ min-width: 26px; min-height: 26px; @@ -936,6 +1683,28 @@ class ChatApp(Gtk.Application): border: 1px solid rgba(0,0,0,0.08); }} + .action-dim {{ + background: rgba(0,0,0,0.58); + border-radius: 12px; + }} + + window.light .action-dim {{ + background: rgba(255,255,255,0.45); + }} + + .action-center-btn {{ + min-width: 34px; + min-height: 34px; + border-radius: 999px; + background: rgba(0,0,0,0.72); + color: white; + }} + + window.light .action-center-btn {{ + background: rgba(255,255,255,0.92); + color: black; + }} + .user-bubble-regen {{ background: #5a3a8a; /* koyu mor (dark mode) */ border: 1px solid rgba(255,255,255,0.08); @@ -976,7 +1745,9 @@ class ChatApp(Gtk.Application): }} .selected {{ - outline: 2px solid #ff9800; + outline: none; + box-shadow: inset 0 0 0 2px #FFD54F; + border-radius: 12px; }} @@ -1019,17 +1790,55 @@ class ChatApp(Gtk.Application): self.win.add_css_class("dark" if is_dark else "light") def toggle_theme(self, *_): - cfg = self.load_config() - current = bool(cfg.get("dark_mode", True)) - new_value = not current - cfg["dark_mode"] = new_value + cfg = ensure_base_config(self.load_config()) + current = cfg["dark_mode"] + cfg["dark_mode"] = not current self.save_config(cfg) - self.apply_theme(new_value) + self.apply_theme(cfg["dark_mode"]) self.refresh_visual_theme_only() # ---------------- INPUT ---------------- + def on_input_buffer_changed(self, *_): + # Özellikle Ctrl+A + Delete gibi toplu silmelerde + # layout iki turda oturabiliyor. + GLib.idle_add(self._update_input_layout_after_text_change) + GLib.timeout_add(20, self._update_input_layout_after_text_change) + + def _update_input_layout_after_text_change(self): + try: + # layout bir tur daha otursun diye spacer hesaplamasını idle'da yap + self._update_bottom_spacer_height() + except Exception: + pass + return False + + def _update_bottom_spacer_height(self, *_): + try: + card = getattr(self, "input_card", None) + spacer = getattr(self, "bottom_spacer", None) + + if card is None or spacer is None: + return False + + input_height = int(card.get_allocated_height() or 0) + + # ilk açılış / geçici layout anlarında 0 gelirse + # minimum güvenli değere dön + if input_height <= 0: + input_height = 80 + + spacer.set_size_request(-1, input_height + 20) + + # scroll butonu da aynı mantıkla yeniden yerleşsin + GLib.idle_add(self._sync_scroll_btn_margin) + + except Exception: + pass + + return False + def force_focus_entry(self): self.textview.grab_focus() return False @@ -1142,19 +1951,133 @@ class ChatApp(Gtk.Application): print("download_image_to_cache error:", e) return None - def finalize_ai_response(self, raw_text: str): + def update_stream_text(self, text, chat_path_for_ai=None): + chat_path_for_ai = Path(chat_path_for_ai or self.current_chat) + text = str(text or "") + + if not text: + return False + + key = self._chat_key(chat_path_for_ai) + self.stream_active_chats.add(key) + + if Path(self.current_chat).resolve() == chat_path_for_ai.resolve(): + self.typing_active = False + try: + self.typing_row.set_visible(False) + except Exception: + pass + + try: + chat_data = self.load_chat_data_ui(chat_path_for_ai) + messages = chat_data["messages"] + except Exception: + return False + + bot_idx = None + + for i in range(len(messages) - 1, -1, -1): + msg = messages[i] + if ( + isinstance(msg, dict) + and msg.get("role") in ("bot", "assistant") + and msg.get("streaming") + ): + bot_idx = i + break + + if bot_idx is None: + messages.append({ + "role": "bot", + "content": "", + "streaming": True + }) + bot_idx = len(messages) - 1 + + messages[bot_idx]["content"] = str(messages[bot_idx].get("content") or "") + text + + try: + chat_data["messages"] = messages + self.save_chat_data_ui(chat_data, chat_path_for_ai) + except Exception: + pass + + # Ekrandaki aktif chat bu değilse sadece dosyaya yaz, UI'yi elleme + if Path(self.current_chat) != chat_path_for_ai: + return False + + # İlk stream parçasında ekranda bubble yoksa sadece bir kere load et + if key not in self.stream_ui_ready_chats: + self.stream_ui_ready_chats.add(key) + self.load_chat() + self.scroll_to_bottom(force=True) + return False + + # Sonraki parçalarda tüm chat'i yeniden yükleme. + # Bu, paralel chatlerde UI callback'lerinin birbirini beklemesine sebep olur. + if Path(self.current_chat).resolve() == chat_path_for_ai.resolve(): + self.refresh_visible_streaming_message(chat_path_for_ai) + self.scroll_to_bottom(force=True) + + return False + + def refresh_visible_streaming_message(self, chat_path_for_ai): + try: + if Path(self.current_chat).resolve() != Path(chat_path_for_ai).resolve(): + return False + + # Şimdilik güvenli minimal çözüm: + # load_chat'i her chunk'ta değil, daha seyrek çalıştır. + now = datetime.now().timestamp() + last = getattr(self, "_last_stream_ui_refresh", 0) + + if now - last < 0.12: + return False + + self._last_stream_ui_refresh = now + self.load_chat() + return False + + except Exception: + return False + + def finalize_ai_response(self, raw_text: str, chat_path_for_ai=None): + chat_path_for_ai = Path(chat_path_for_ai or self.current_chat) raw_text = (raw_text or "").strip() try: data = json.loads(raw_text) except Exception: - data = {"type": "text", "content": raw_text} + data = None + + # stdout içine yanlışlıkla debug satırı karışırsa son JSON objesini yakala + start = raw_text.rfind("\n{") + if start != -1: + candidate = raw_text[start + 1:].strip() + else: + start = raw_text.find("{") + candidate = raw_text[start:].strip() if start != -1 else "" + + if candidate: + try: + data = json.loads(candidate) + except Exception: + data = None + + if not isinstance(data, dict): + data = {"type": "text", "content": raw_text} try: - with open(self.current_chat, "r", encoding="utf-8") as f: - messages = json.load(f) or [] + chat_data = self.load_chat_data_ui(chat_path_for_ai) + messages = chat_data["messages"] except Exception: - messages = [] + chat_data = { + "summary": "", + "messages": [], + "code_context": {}, + "memory_chunks": [] + } + messages = chat_data["messages"] # son streaming bot placeholder'ını bul bot_idx = None @@ -1174,8 +2097,21 @@ class ChatApp(Gtk.Application): result_type = str(data.get("type") or "text").strip().lower() bot_msg["content"] = str(data.get("content") or "").strip() + web_req = data.get("web_search_request") + + if isinstance(web_req, dict): + bot_msg["web_search_request"] = { + "query": str(web_req.get("query") or "").strip(), + "status": str(web_req.get("status") or "pending").strip(), + "err": str(web_req.get("err") or "").strip(), + "sources": web_req.get("sources") if isinstance(web_req.get("sources"), list) else [] + } + else: + bot_msg.pop("web_search_request", None) + saved_images = [] seen_saved = set() + seen_image_keys = set() # usage varsa kaydet usage = data.get("usage") @@ -1245,7 +2181,9 @@ class ChatApp(Gtk.Application): for item in inline_data_urls: if item.startswith("data:image/") and "," in item: - base64_candidates.append(item.split(",", 1)[1].strip()) + b64 = item.split(",", 1)[1].strip() + if b64: + base64_candidates.append(b64) one_b64 = data.get("image_base64") if isinstance(one_b64, str) and one_b64.strip(): @@ -1265,6 +2203,13 @@ class ChatApp(Gtk.Application): if item.startswith("data:image/") and "," in item: item = item.split(",", 1)[1].strip() + image_key = item[:200] + str(len(item)) + + if image_key in seen_image_keys: + continue + + seen_image_keys.add(image_key) + local_path = self.save_base64_image(item, "png") if local_path and local_path not in seen_saved: seen_saved.add(local_path) @@ -1272,14 +2217,47 @@ class ChatApp(Gtk.Application): if saved_images: bot_msg["images"] = saved_images + + if not str(bot_msg.get("content") or "").strip(): + bot_msg["content"] = ( + self("o_Image_Created") + if self("o_Image_Created") != "o_Image_Created" + else self("o_Image_Created") + ) + else: bot_msg.pop("images", None) + generated_files = data.get("generated_files") + + if isinstance(generated_files, list): + clean_files = [] + + for item in generated_files: + if not isinstance(item, dict): + continue + + path = str(item.get("path") or "").strip() + name = str(item.get("name") or "").strip() + + if path and Path(path).exists() and Path(path).is_file(): + clean_files.append({ + "path": str(Path(path).resolve()), + "name": name or Path(path).name + }) + + if clean_files: + bot_msg["generated_files"] = clean_files + else: + bot_msg.pop("generated_files", None) + else: + bot_msg.pop("generated_files", None) + messages[bot_idx] = bot_msg try: - with open(self.current_chat, "w", encoding="utf-8") as f: - json.dump(messages, f, ensure_ascii=False, indent=2) + chat_data["messages"] = messages + self.save_chat_data_ui(chat_data, chat_path_for_ai) except Exception as e: print("finalize_ai_response write error:", e) @@ -1493,7 +2471,16 @@ class ChatApp(Gtk.Application): def _capture_photo_from_script(self): script = Path.home() / ".config" / "scripts" / "screenprint.sh" if not script.exists(): - GLib.idle_add(self.handle_ai_error, f"Script bulunamadı: {script}") + GLib.idle_add( + self.handle_ai_error, + { + "role": "bot", + "error_key": "o_Script_Not_Found", + "error_params": { + "path": str(script) + } + } + ) return watch_dirs = [Path.home() / "Resimler", Path.home() / "Pictures", Path("/tmp")] @@ -1573,7 +2560,16 @@ class ChatApp(Gtk.Application): GLib.idle_add(self.show_image_preview, str(newest.resolve())) except Exception as e: - GLib.idle_add(self.handle_ai_error, f"Fotoğraf çekme hatası: {e}") + GLib.idle_add( + self.handle_ai_error, + { + "role": "bot", + "error_key": "o_Photo_Capture_Error", + "error_params": { + "error": str(e) + } + } + ) def open_attach_menu(self, button: Gtk.Button): pop = Gtk.Popover() @@ -1617,6 +2613,263 @@ class ChatApp(Gtk.Application): pop.set_child(box) pop.popup() + def setup_file_drop_target(self, widget): + from urllib.parse import urlparse, unquote + + formats = Gdk.ContentFormats.new([ + "text/uri-list", + "x-special/gnome-copied-files", + ]) + + drop_target = Gtk.DropTargetAsync.new( + formats, + Gdk.DragAction.COPY + ) + + def parse_uri_text(text: str): + paths = [] + + for line in (text or "").splitlines(): + line = line.strip() + + if not line or line.startswith("#"): + continue + + if line in ("copy", "cut"): + continue + + if line.startswith("file://"): + line = unquote(urlparse(line).path) + + p = Path(line).expanduser() + + if p.exists() and p.is_file(): + paths.append(str(p.resolve())) + + return paths + + def on_accept(_target, drop): + print("DROP ACCEPT:", drop.get_formats().to_string(), flush=True) + return True + + def on_drag_enter(_target, drop, x, y): + print("DROP ENTER:", drop.get_formats().to_string(), flush=True) + return Gdk.DragAction.COPY + + def on_drag_motion(_target, drop, x, y): + print("DROP MOTION", flush=True) + return Gdk.DragAction.COPY + + def on_drop(_target, drop, _x, _y): + print("ASYNC DROP GELDI", flush=True) + print("DROP FORMATS:", drop.get_formats().to_string(), flush=True) + + def finish_paths(paths): + paths = [p for p in paths if p and Path(p).exists() and Path(p).is_file()] + print("DROP PATHS:", paths, flush=True) + + ok = bool(paths) + if ok: + self.add_dropped_files(paths) + GLib.idle_add(self.refresh_attachments_preview) + GLib.idle_add(self._sync_scroll_btn_margin) + + drop.finish(Gdk.DragAction.COPY if ok else 0) + + # 1) Önce Gdk.FileList oku + def on_filelist_read(drop_obj, result): + try: + value = drop_obj.read_value_finish(result) + files = value.get_files() + paths = [] + + for gfile in files: + p = gfile.get_path() + if p: + paths.append(str(Path(p).resolve())) + + finish_paths(paths) + return + + except Exception as e: + print("Gdk.FileList read error:", e, flush=True) + + # 2) Olmazsa Gio.File oku + def on_gfile_read(drop_obj2, result2): + try: + value2 = drop_obj2.read_value_finish(result2) + p = value2.get_path() + finish_paths([str(Path(p).resolve())] if p else []) + return + + except Exception as e2: + print("Gio.File read error:", e2, flush=True) + + # 3) En son text/uri-list oku + def on_text_read(drop_obj3, result3): + try: + value3 = drop_obj3.read_value_finish(result3) + text = str(value3 or "") + print("DROP RAW TEXT:", repr(text), flush=True) + finish_paths(parse_uri_text(text)) + return + + except Exception as e3: + print("text/uri-list read error:", e3, flush=True) + drop.finish(False) + + drop.read_value_async( + str, + GLib.PRIORITY_DEFAULT, + None, + on_text_read + ) + + drop.read_value_async( + Gio.File, + GLib.PRIORITY_DEFAULT, + None, + on_gfile_read + ) + + drop.read_value_async( + Gdk.FileList, + GLib.PRIORITY_DEFAULT, + None, + on_filelist_read + ) + + return True + + drop_target.connect("accept", on_accept) + drop_target.connect("drag-enter", on_drag_enter) + drop_target.connect("drag-motion", on_drag_motion) + drop_target.connect("drop", on_drop) + + widget.add_controller(drop_target) + + def get_prompt_chooser_blocks(self) -> list[str]: + cfg = ensure_base_config(self.load_config()) + blocks = cfg.get("prompt_chooser_blocks", ["copyable"]) + + if not isinstance(blocks, list): + blocks = ["copyable"] + + valid = { + "copyable", + "apply", + "file_create", + "web_search", + "structured", + "code", + "pdf_text", + "pdf_image", + "pdf_text_image", + } + + return [str(x) for x in blocks if str(x) in valid] + + + def save_prompt_chooser_blocks(self, blocks: list[str]): + cfg = ensure_base_config(self.load_config()) + cfg["prompt_chooser_blocks"] = list(blocks) + self.save_config(cfg) + + + def open_prompt_chooser_menu(self, button: Gtk.Button): + pop = Gtk.Popover() + pop.set_parent(button) + pop.set_position(Gtk.PositionType.TOP) + pop.set_has_arrow(True) + pop.set_autohide(True) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + box.set_margin_top(10) + box.set_margin_bottom(10) + box.set_margin_start(10) + box.set_margin_end(10) + + title = Gtk.Label(label=self("o_Prompt_Chooser")) + title.set_xalign(0) + title.add_css_class("dim-label") + box.append(title) + + selected = set(self.get_prompt_chooser_blocks()) + + items = [ + ("copyable", "o_PC_Copyable"), + ("apply", "o_PC_Apply"), + ("file_create", "o_PC_FileCreate"), + ("web_search", "o_PC_WebSearch"), + ("structured", "o_PC_Structured"), + ("code", "o_PC_Code"), + ("pdf_text", "o_PC_PDFText"), + ("pdf_image", "o_PC_PDFImage"), + ("pdf_text_image", "o_PC_PDFTextImage"), + ] + + checks = {} + + def refresh_config(*_): + new_selected = [] + for key, check in checks.items(): + if check.get_active(): + new_selected.append(key) + + # Boş listeye izin var + self.save_prompt_chooser_blocks(new_selected) + + for key, label in items: + check = Gtk.CheckButton(label=self(label)) + check.set_active(key in selected) + check.set_halign(Gtk.Align.START) + check.connect("toggled", refresh_config) + checks[key] = check + box.append(check) + + pop.set_child(box) + pop.popup() + + def add_dropped_files(self, paths): + import mimetypes + + changed = False + + for path in paths: + if not path: + continue + + p = Path(path).expanduser() + + if not p.exists() or not p.is_file(): + continue + + path_str = str(p.resolve()) + mime, _ = mimetypes.guess_type(path_str) + mime = (mime or "").lower() + + if mime.startswith("image/"): + if path_str not in self.pending_images: + self.pending_images.append(path_str) + changed = True + else: + if not any(x.get("path") == path_str for x in self.pending_files): + self.pending_files.append({ + "path": path_str, + "name": p.name, + "edit": False + }) + changed = True + + if changed: + print("PENDING IMAGES:", self.pending_images) + print("PENDING FILES:", self.pending_files) + + self.refresh_attachments_preview() + GLib.idle_add(self.refresh_attachments_preview) + GLib.idle_add(self._sync_scroll_btn_margin) + + def on_attach_clicked(self, *_): def on_paths_selected(paths): if not paths: @@ -1878,103 +3131,263 @@ class ChatApp(Gtk.Application): # ---------------- AI ERROR / TYPING ---------------- - def handle_ai_error(self, error_text): - self.typing_active = False - self.stream_active = False - self.ai_proc = None - self.set_generating_state(False) + def handle_ai_error(self, error_text, chat_path_for_ai=None): + chat_path_for_ai = Path(chat_path_for_ai or self.current_chat) + key = self._chat_key(chat_path_for_ai) + + self.stream_active_chats.discard(key) + self.stream_ui_ready_chats.discard(key) + self.ai_procs.pop(key, None) + self.set_generating_state(False, chat_path_for_ai) + + if Path(self.current_chat).resolve() == chat_path_for_ai.resolve(): + self.typing_active = False + + is_active_chat = Path(self.current_chat).resolve() == chat_path_for_ai.resolve() + + if is_active_chat: + try: + self.typing_row.set_visible(False) + except Exception: + pass + try: - self.typing_row.set_visible(False) + chat_data = self.load_chat_data_ui(chat_path_for_ai) + messages = chat_data["messages"] + except Exception: + chat_data = { + "summary": "", + "messages": [], + "code_context": {}, + "memory_chunks": [] + } + messages = chat_data["messages"] + + # Error gelince bekleyen streaming placeholder'ı temizle + for i in range(len(messages) - 1, -1, -1): + msg = messages[i] + + if not isinstance(msg, dict): + continue + + if ( + msg.get("role") in ("bot", "assistant") + and msg.get("streaming") + ): + messages.pop(i) + break + + if msg.get("role") == "user": + break + + err_msg = None + + # 1) Doğrudan dict geldiyse + if isinstance(error_text, dict): + err_msg = dict(error_text) + + else: + # 2) JSON string geldiyse çöz + parsed = None + try: + parsed = json.loads(str(error_text)) + except Exception: + parsed = None + + if isinstance(parsed, dict): + # structured error + if parsed.get("error_key"): + err_msg = { + "role": "bot", + "error_key": parsed.get("error_key"), + "error_params": parsed.get("error_params") or {} + } + # backend'den gelen type=text + elif parsed.get("type") == "text": + err_msg = { + "role": "bot", + "error_key": "o_Error_With_Detail", + "error_params": { + "error": str(parsed.get("content") or "").strip() + } + } + + # 3) Hiçbiri değilse düz string fallback + if not isinstance(err_msg, dict): + err_msg = { + "role": "bot", + "error_key": "o_Error_With_Detail", + "error_params": { + "error": str(error_text or "").strip() + } + } + + messages.append(err_msg) + + try: + chat_data["messages"] = messages + self.save_chat_data_ui(chat_data, chat_path_for_ai) except Exception: pass - try: - with open(self.current_chat, "r", encoding="utf-8") as f: - messages = json.load(f) or [] - cfg = self.load_config() - show_usage = bool(cfg.get("show_usage", False)) - except Exception: - messages = [] - - messages.append({"role": "bot", "content": f"❌ Hata:\n{error_text}"}) - - try: - with open(self.current_chat, "w", encoding="utf-8") as f: - json.dump(messages, f, ensure_ascii=False, indent=2) - except Exception: - pass - - self.load_chat() - self.scroll_to_bottom() + if Path(self.current_chat) == chat_path_for_ai: + self.load_chat() + self.scroll_to_bottom(force=True) def animate_typing(self): if not getattr(self, "typing_active", False): + self._typing_timer_active = False return False - # stream başlayınca noktaları durdur - if getattr(self, "stream_active", False): + current_key = self._chat_key(self.current_chat) + + if current_key in self.stream_active_chats: + self.typing_active = False + try: + self.typing_row.set_visible(False) + except Exception: + pass + + self._typing_timer_active = False return False dots = "." * (self.typing_dots % 4) self.typing_label.set_text(f"{self('o_Thinking')}{dots}") self.typing_dots += 1 + return True - def show_typing_indicator(self): - self.stream_active = False + def show_typing_indicator(self, chat_path=None): + chat_path = Path(chat_path or self.current_chat) + key = self._chat_key(chat_path) + + # Bu chat zaten stream'e geçtiyse thinking gösterme + if key in self.stream_active_chats: + return + + # Sadece ekrandaki aktif chat için görünür yap + if Path(self.current_chat).resolve() != chat_path.resolve(): + return + self.typing_active = True - self.typing_label.set_text(f"{self('o_Thinking')}...") - self.typing_row.set_visible(True) self.typing_dots = 0 - GLib.timeout_add(500, self.animate_typing) + + self.typing_label.set_text(self("o_Thinking")) + self.typing_row.set_visible(True) + + if not getattr(self, "_typing_timer_active", False): + self._typing_timer_active = True + GLib.timeout_add(500, self.animate_typing) def hide_typing_indicator(self): self.typing_active = False - self.stream_active = False try: self.typing_row.set_visible(False) except Exception: pass - def after_ai_response(self): - self.hide_typing_indicator() - self.set_generating_state(False) + def chat_has_streaming_placeholder(self, chat_path=None) -> bool: + try: + chat_data = self.load_chat_data_ui(chat_path or self.current_chat) + messages = chat_data.get("messages", []) - if getattr(self, "ai_stop_requested", False): + for msg in reversed(messages): + if not isinstance(msg, dict): + continue + + if msg.get("role") in ("bot", "assistant") and msg.get("streaming"): + return True + + # Son gerçek kullanıcıdan önceye geçme + if msg.get("role") == "user": + break + + except Exception: + pass + + return False + + def after_ai_response(self, chat_path_for_ai=None): + chat_path_for_ai = Path(chat_path_for_ai or self.current_chat) + key = self._chat_key(chat_path_for_ai) + + if Path(self.current_chat).resolve() == chat_path_for_ai.resolve(): + self.hide_typing_indicator() + + if key in self.ai_stop_requested_chats: try: - with open(self.current_chat, "r", encoding="utf-8") as f: - messages = json.load(f) or [] + chat_data = self.load_chat_data_ui(chat_path_for_ai) + messages = chat_data["messages"] messages.append({ "role": "info", - "content": self("o_Cancelled") - }) + "i18n_key": "o_Cancelled" + }) + + chat_data["messages"] = messages + self.save_chat_data_ui(chat_data, chat_path_for_ai) - with open(self.current_chat, "w", encoding="utf-8") as f: - json.dump(messages, f, ensure_ascii=False, indent=2) except Exception: pass - self.ai_proc = None - self.ai_stop_requested = False - self.streaming_row = None - self.streaming_label = None - self.load_chat() - self.scroll_to_bottom(force=True) + self.set_generating_state(False, chat_path_for_ai) + + self.ai_procs.pop(key, None) + self.ai_stop_requested_chats.discard(key) + self.stream_active_chats.discard(key) + self.stream_ui_ready_chats.discard(key) + + if Path(self.current_chat).resolve() == chat_path_for_ai.resolve(): + self.streaming_row = None + self.streaming_label = None + + if Path(self.current_chat) == chat_path_for_ai: + try: + chat_data = self.load_chat_data_ui(chat_path_for_ai) + messages = chat_data["messages"] + + for msg in reversed(messages): + if ( + isinstance(msg, dict) + and msg.get("role") in ("bot", "assistant") + and msg.get("streaming") + ): + msg.pop("streaming", None) + break + + chat_data["messages"] = messages + self.save_chat_data_ui(chat_data, chat_path_for_ai) + except Exception: + pass + + self.load_chat() + self.scroll_to_bottom(force=True) # ---------------- MODELS LIST ---------------- def apply_models_visibility(self): if not getattr(self, "sidebar_expanded", True): + try: + self.context_mode_row.set_visible(False) + except Exception: + pass + self.models_header.set_visible(False) self.models_list.set_visible(False) return + try: + self.context_mode_row.set_visible(True) + except Exception: + pass + self.models_header.set_visible(True) self.models_list.set_visible(self.models_list_open) - self.models_toggle_btn.set_label(f"{self('o_AI_Models')} ▾" if self.models_list_open else f"{self('o_AI_Models')} ▸") + self.models_toggle_btn.set_label( + f"{self('o_AI_Models')} ▾" if self.models_list_open else f"{self('o_AI_Models')} ▸" + ) def on_model_row_activated(self, listbox, row): if not row: @@ -1984,17 +3397,38 @@ class ChatApp(Gtk.Application): if not model_id: return - self.set_chat_model(self.current_chat, model_id) + cfg = ensure_base_config(self.load_config()) + found = self._find_model_entry(cfg, model_id) + is_local = bool(found.get("local", False)) if found else False + + self.set_chat_model(self.current_chat, model_id, is_local) def toggle_models_list(self, *_): self.models_list_open = not self.models_list_open self.apply_models_visibility() + def refresh_context_mode_switch(self): + try: + if not hasattr(self, "context_mode_switch"): + return + + mode = self.get_context_mode(self.current_chat) + + # switch kapalı: rag + # switch açık: direct + self.context_mode_switch.set_active(mode == "direct") + + except Exception: + pass + + def on_context_mode_switch_changed(self, switch, _pspec): + mode = "direct" if switch.get_active() else "rag" + self.set_context_mode(mode, self.current_chat) + def confirm_delete_model(self, button, model_id: str): - cfg = self.load_config() + cfg = ensure_base_config(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) @@ -2025,32 +3459,37 @@ class ChatApp(Gtk.Application): def on_response(d, resp): if resp == Gtk.ResponseType.OK: - cfg2 = self.load_config() + cfg2 = ensure_base_config(self.load_config()) models2 = self._ensure_min_one_model(cfg2) if len(models2) <= 1: d.destroy() return - models2 = [m for m in models2 if m != model_id] + models2 = [m for m in models2 if m["id"] != 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 = { + "id": str(models2[0]["id"]), + "local": bool(models2[0].get("local", False)) + } if models2 else { + "id": "google/gemini-2.5-flash-image", + "local": False + } - new_default = str(models2[0]) if models2 else None + chat_models = cfg2["chat_models"] for k, v in list(chat_models.items()): - if v == model_id: - chat_models[k] = new_default - cfg2["chat_models"] = chat_models + if isinstance(v, dict): + vid = str(v.get("id") or "").strip() + if vid == model_id: + chat_models[k] = dict(new_default) + elif isinstance(v, str) and v == model_id: + chat_models[k] = dict(new_default) + cfg2["chat_models"] = chat_models self.save_config(cfg2) - # aktif chat modeli self-heal self.active_model = self.get_chat_model(self.current_chat) - self.load_models_list() self.apply_models_visibility() @@ -2076,6 +3515,9 @@ class ChatApp(Gtk.Application): entry.set_placeholder_text("provider/model (exp: openai/gpt-4.1-mini)") content.append(entry) + local_check = Gtk.CheckButton(label="Local model") + content.append(local_check) + # Ctrl+V ile yapıştırmayı garanti et key_controller = Gtk.EventControllerKey() @@ -2121,8 +3563,8 @@ class ChatApp(Gtk.Application): 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) + is_local = local_check.get_active() + self.set_chat_model(self.current_chat, model_id, is_local) d.destroy() dialog.connect("response", on_response) @@ -2140,16 +3582,17 @@ class ChatApp(Gtk.Application): break self.models_list.remove(row) - cfg = self.load_config() + cfg = ensure_base_config(self.load_config()) models = self._ensure_min_one_model(cfg) active = self.get_chat_model(self.current_chat) self.active_model = active - for model_id in models: - model_id = str(model_id) + for model in models: + model_id = str(model["id"]) + is_local = bool(model.get("local", False)) row = Gtk.ListBoxRow() - row.model_id = model_id # <-- önemli + row.model_id = model_id box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) box.set_hexpand(True) @@ -2162,6 +3605,10 @@ class ChatApp(Gtk.Application): label.set_single_line_mode(True) label.set_ellipsize(Pango.EllipsizeMode.END) label.set_max_width_chars(22) + + if is_local: + label.add_css_class("model-local-label") + box.append(label) trash_btn = Gtk.Button(label="🗑") @@ -2326,20 +3773,26 @@ class ChatApp(Gtk.Application): return # pinned update + last_chat update + chat_models update - cfg = self.load_config() + cfg = ensure_base_config(self.load_config()) - plist = cfg.get("pinned_chats", []) - if isinstance(plist, list) and old_path.name in plist: + plist = cfg["pinned_chats"] + if old_path.name in plist: cfg["pinned_chats"] = [new_path.name if x == old_path.name else x for x in plist] - if cfg.get("last_chat") == old_path.name: + if cfg["last_chat"] == old_path.name: cfg["last_chat"] = new_path.name - chat_models = cfg.get("chat_models", {}) + chat_models = cfg["chat_models"] + if isinstance(chat_models, dict) and old_path.name in chat_models: chat_models[new_path.name] = chat_models.pop(old_path.name) cfg["chat_models"] = chat_models + chat_rag_switch = cfg.setdefault("chat_rag_switch", {}) + if old_path.name in chat_rag_switch: + chat_rag_switch[new_path.name] = chat_rag_switch.pop(old_path.name) + cfg["chat_rag_switch"] = chat_rag_switch + self.save_config(cfg) if self.current_chat == old_path: @@ -2354,10 +3807,8 @@ class ChatApp(Gtk.Application): # ---------------- 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 = [] + cfg = ensure_base_config(self.load_config()) + pinned = cfg["pinned_chats"] name = file_path.name if name in pinned: @@ -2498,8 +3949,244 @@ class ChatApp(Gtk.Application): except Exception: pass + def open_stt_settings_dialog(self): + cfg = ensure_base_config(self.load_config()) + + dialog = Gtk.Dialog(transient_for=self.win) + self.bind_i18n(dialog, "title", "o_STT_Settings") + dialog.set_modal(True) + dialog.set_default_size(520, 380) + + + content = dialog.get_content_area() + content.set_spacing(10) + content.set_margin_top(10) + content.set_margin_bottom(10) + content.set_margin_start(10) + content.set_margin_end(10) + + # --- Online STT --- + row_online = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + + online_label_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + online_label_box.set_hexpand(True) + + online_title = Gtk.Label() + self.bind_i18n(online_title, "label", "o_Online_STT_Text") + online_title.set_xalign(0) + online_title.set_halign(Gtk.Align.START) + + online_desc = Gtk.Label() + self.bind_i18n(online_desc, "label", "o_Online_STT_Hint") + online_desc.set_xalign(0) + online_desc.set_halign(Gtk.Align.START) + online_desc.set_wrap(True) + online_desc.add_css_class("dim-label") + + online_label_box.append(online_title) + online_label_box.append(online_desc) + + online_switch = Gtk.Switch() + online_switch.set_active(cfg["is_mic_online"]) + online_switch.set_halign(Gtk.Align.END) + online_switch.set_valign(Gtk.Align.CENTER) + + row_online.append(online_label_box) + row_online.append(online_switch) + content.append(row_online) + + # --- Desktop Voice --- + row_desktop = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + + desktop_label_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + desktop_label_box.set_hexpand(True) + + desktop_title = Gtk.Label() + self.bind_i18n(desktop_title, "label", "o_Use_Desktop_Audio_Text") + desktop_title.set_xalign(0) + desktop_title.set_halign(Gtk.Align.START) + + desktop_desc = Gtk.Label() + self.bind_i18n(desktop_desc, "label", "o_Use_Desktop_Audio_Hint") + desktop_desc.set_xalign(0) + desktop_desc.set_halign(Gtk.Align.START) + desktop_desc.set_wrap(True) + desktop_desc.add_css_class("dim-label") + + desktop_label_box.append(desktop_title) + desktop_label_box.append(desktop_desc) + + desktop_switch = Gtk.Switch() + desktop_switch.set_active(cfg["use_desktop_voice"]) + desktop_switch.set_halign(Gtk.Align.END) + desktop_switch.set_valign(Gtk.Align.CENTER) + + row_desktop.append(desktop_label_box) + row_desktop.append(desktop_switch) + content.append(row_desktop) + + # --- Online model --- + stt_model_label = Gtk.Label() + self.bind_i18n(stt_model_label, "label", "o_Online_STT_Model") + stt_model_label.set_xalign(0) + content.append(stt_model_label) + + stt_model_entry = Gtk.Entry() + stt_model_entry.set_placeholder_text("openai/gpt-audio-mini") + stt_model_entry.set_text(str(cfg["stt_model_online"])) + content.append(stt_model_entry) + + # --- whisper.cpp binary --- + whisper_bin_label = Gtk.Label(label=f"whisper.cpp {self('o_Binary_Path')}") + whisper_bin_label.set_xalign(0) + content.append(whisper_bin_label) + + whisper_bin_entry = Gtk.Entry() + whisper_bin_entry.set_placeholder_text("/home/usr/whisper.cpp/build/bin/whisper-cli") + whisper_bin_entry.set_text(str(cfg["whisper_cpp_bin"])) + content.append(whisper_bin_entry) + + # --- whisper.cpp model --- + whisper_model_label = Gtk.Label(label=f"whisper.cpp {self('o_Model_Path')}") + whisper_model_label.set_xalign(0) + content.append(whisper_model_label) + + whisper_model_entry = Gtk.Entry() + whisper_model_entry.set_placeholder_text("/home/usr/.local/share/whisper/ggml-tiny.bin") + whisper_model_entry.set_text(str(cfg["whisper_cpp_model"])) + content.append(whisper_model_entry) + + # Link + link = Gtk.LinkButton.new_with_label( + "https://openrouter.ai/models?fmt=cards&input_modalities=audio", + "OpenRouter – STT Models Page" + ) + link.set_halign(Gtk.Align.CENTER) + content.append(link) + + dialog.add_button(self("o_Cancel"), Gtk.ResponseType.CANCEL) + dialog.add_button(self("o_Save"), Gtk.ResponseType.OK) + + def refresh_sensitive_state(*_): + is_online = bool(online_switch.get_active()) + + stt_model_label.set_sensitive(is_online) + stt_model_entry.set_sensitive(is_online) + + whisper_bin_label.set_sensitive(not is_online) + whisper_bin_entry.set_sensitive(not is_online) + whisper_model_label.set_sensitive(not is_online) + whisper_model_entry.set_sensitive(not is_online) + + online_switch.connect("notify::active", refresh_sensitive_state) + refresh_sensitive_state() + + def on_response(d, resp): + if resp == Gtk.ResponseType.OK: + cfg2 = ensure_base_config(self.load_config()) + + cfg2["is_mic_online"] = bool(online_switch.get_active()) + cfg2["use_desktop_voice"] = bool(desktop_switch.get_active()) + cfg2["stt_model_online"] = stt_model_entry.get_text().strip() + cfg2["whisper_cpp_bin"] = whisper_bin_entry.get_text().strip() + cfg2["whisper_cpp_model"] = whisper_model_entry.get_text().strip() + + self.save_config(cfg2) + + d.close() + + dialog.connect("response", on_response) + dialog.present() + + def _show_info_dialog(self, title: str, message: str): + dialog = Gtk.Dialog(transient_for=self.win) + dialog.set_modal(True) + dialog.set_title(title) + + content = dialog.get_content_area() + label = Gtk.Label(label=message) + label.set_wrap(True) + label.set_xalign(0) + content.append(label) + + dialog.add_button(self("o_OK"), Gtk.ResponseType.OK) + dialog.connect("response", lambda d, r: d.destroy()) + dialog.present() + + + def _run_provider_command(self, command: str, provider_name: str, action_name: str): + cmd = str(command or "").strip() + if not cmd: + self._show_info_dialog( + f"{action_name} {self('o_Error')}", + self("o_Provider_Command_No_Command", provider=provider_name) + ) + return + + try: + # bash ile çalıştır: terminaldeki quote davranışına daha yakın olur + p = subprocess.Popen( + ["bash", "-lc", cmd], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + ) + + def _wait(): + try: + out, err = p.communicate(timeout=15) + rc = p.returncode + except subprocess.TimeoutExpired: + try: + p.kill() + except Exception: + pass + GLib.idle_add( + self._show_info_dialog, + f"{action_name} {self('o_Timeout')}", + self("o_Provider_Command_Timeout", provider=provider_name, command=cmd) + ) + return + except Exception as e: + GLib.idle_add( + self._show_info_dialog, + f"{action_name} {self('o_Error')}", + self("o_Provider_Command_Error", provider=provider_name, error=e) + ) + return + + out = (out or "").strip() + err = (err or "").strip() + + if rc == 0: + msg = self("o_Provider_Command_Success", provider=provider_name, command=cmd) + if out: + msg += f"\n\nOutput:\n{out[:1200]}" + GLib.idle_add(self._show_info_dialog, action_name, msg) + else: + msg = self("o_Provider_Command_Failed", provider=provider_name, command=cmd, code=rc) + if err: + msg += f"\n\nError:\n{err[:1200]}" + elif out: + msg += f"\n\nOutput:\n{out[:1200]}" + GLib.idle_add(self._show_info_dialog, f"{action_name} {self('o_Failed')}", msg) + + threading.Thread(target=_wait, daemon=True).start() + + except Exception as e: + self._show_info_dialog( + f"{action_name} {self('o_Error')}", + self("o_Provider_Command_Error", provider=provider_name, error=e) + ) + + + def stop_selected_provider(self, provider_name: str, provider_cfg: dict): + stop_cmd = str((provider_cfg or {}).get("stop_command") or "").strip() + self._run_provider_command(stop_cmd, provider_name, self("o_Stop_Provider")) + def open_ai_settings_dialog(self): - cfg = self.load_config() + cfg = ensure_base_config(self.load_config()) dialog = Gtk.Dialog(transient_for=self.win) self.bind_i18n(dialog, "title", "o_AI_Settings") @@ -2536,7 +4223,7 @@ class ChatApp(Gtk.Application): response_label_box.append(response_desc) response_switch = Gtk.Switch() - response_switch.set_active(bool(cfg.get("force_ui_language", False))) + response_switch.set_active(cfg["force_ui_language"]) response_switch.set_halign(Gtk.Align.END) response_switch.set_valign(Gtk.Align.CENTER) @@ -2544,134 +4231,438 @@ class ChatApp(Gtk.Application): row_response.append(response_switch) content.append(row_response) - # --- Online STT --- - row_online = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + # --- ASK FOR WEB SEARCH --- - online_label_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - online_label_box.set_hexpand(True) + row_ask_web = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - online_title = Gtk.Label() - self.bind_i18n(online_title, "label", "o_Online_STT_Text") - online_title.set_xalign(0) - online_title.set_halign(Gtk.Align.START) + ask_web_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + ask_web_box.set_hexpand(True) - online_desc = Gtk.Label() - self.bind_i18n(online_desc, "label", "o_Online_STT_Hint") - online_desc.set_xalign(0) - online_desc.set_halign(Gtk.Align.START) - online_desc.set_wrap(True) - online_desc.add_css_class("dim-label") + ask_web_title = Gtk.Label() + self.bind_i18n(ask_web_title, "label", "o_Ask_Web_Search") + ask_web_title.set_xalign(0) + ask_web_title.set_halign(Gtk.Align.START) - online_label_box.append(online_title) - online_label_box.append(online_desc) + ask_web_desc = Gtk.Label() + self.bind_i18n(ask_web_desc, "label", "o_Ask_Web_Search_Hint") + ask_web_desc.set_xalign(0) + ask_web_desc.set_halign(Gtk.Align.START) + ask_web_desc.set_wrap(True) + ask_web_desc.add_css_class("dim-label") - online_switch = Gtk.Switch() - online_switch.set_active(bool(cfg.get("is_mic_online", True))) - online_switch.set_halign(Gtk.Align.END) - online_switch.set_valign(Gtk.Align.CENTER) + ask_web_box.append(ask_web_title) + ask_web_box.append(ask_web_desc) - row_online.append(online_label_box) - row_online.append(online_switch) - content.append(row_online) + ask_web_switch = Gtk.Switch() + ask_web_switch.set_active(bool(cfg.get("ask_for_web_search", True))) + ask_web_switch.set_halign(Gtk.Align.END) + ask_web_switch.set_valign(Gtk.Align.CENTER) - # --- Desktop Voice --- - row_desktop = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row_ask_web.append(ask_web_box) + row_ask_web.append(ask_web_switch) + content.append(row_ask_web) - desktop_label_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - desktop_label_box.set_hexpand(True) + # --- AI Providers --- + sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) + content.append(sep) - desktop_title = Gtk.Label() - self.bind_i18n(desktop_title, "label", "o_Use_Desktop_Audio_Text") - desktop_title.set_xalign(0) - desktop_title.set_halign(Gtk.Align.START) + local_title = Gtk.Label() + self.bind_i18n(local_title, "label", "o_AI_Providers") + local_title.set_xalign(0) + content.append(local_title) - desktop_desc = Gtk.Label() - self.bind_i18n(desktop_desc, "label", "o_Use_Desktop_Audio_Hint") - desktop_desc.set_xalign(0) - desktop_desc.set_halign(Gtk.Align.START) - desktop_desc.set_wrap(True) - desktop_desc.add_css_class("dim-label") + local_providers = cfg.get("local_providers", {}) + if not isinstance(local_providers, dict): + local_providers = {} - desktop_label_box.append(desktop_title) - desktop_label_box.append(desktop_desc) + provider_defaults = { + "enabled": False, + "base_url": "http://127.0.0.1:11434", + "run_startup": "", + "stop_command": "", + "system_error": "", + "temperature": "", + "top_p": "", + "top_k": "", + "repeat_penalty": "", + "num_ctx": "", + "num_predict": "", + "keep_alive": "", + "system_prompt": "" + } - desktop_switch = Gtk.Switch() - desktop_switch.set_active(bool(cfg.get("use_desktop_voice", False))) - desktop_switch.set_halign(Gtk.Align.END) - desktop_switch.set_valign(Gtk.Align.CENTER) + if not local_providers: + local_providers = { + "ollama": dict(provider_defaults) + } - row_desktop.append(desktop_label_box) - row_desktop.append(desktop_switch) - content.append(row_desktop) + provider_names = list(local_providers.keys()) + selected_provider_name = provider_names[0] - # --- Online model --- - stt_model_label = Gtk.Label() - self.bind_i18n(stt_model_label, "label", "o_Online_STT_Model") - stt_model_label.set_xalign(0) - content.append(stt_model_label) + def merged_provider_cfg(provider_name: str) -> dict: + out = dict(provider_defaults) + raw = local_providers.get(provider_name, {}) + if isinstance(raw, dict): + out.update(raw) + return out - stt_model_entry = Gtk.Entry() - stt_model_entry.set_placeholder_text("openai/gpt-audio-mini") - stt_model_entry.set_text(str(cfg.get("stt_model_online", "openai/gpt-audio-mini") or "")) - content.append(stt_model_entry) + local_cfg = merged_provider_cfg(selected_provider_name) - # --- whisper.cpp binary --- - whisper_bin_label = Gtk.Label(label=f"whisper.cpp {self('o_Binary_Path')}") - whisper_bin_label.set_xalign(0) - content.append(whisper_bin_label) + provider_select_label = Gtk.Label() + self.bind_i18n(provider_select_label, "label", "o_Selected_Provider") + provider_select_label.set_xalign(0) + content.append(provider_select_label) - whisper_bin_entry = Gtk.Entry() - whisper_bin_entry.set_placeholder_text("/usr/bin/whisper-cli") - whisper_bin_entry.set_text(str(cfg.get("whisper_cpp_bin", "") or "")) - content.append(whisper_bin_entry) + provider_select_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + provider_select_row.set_hexpand(True) + content.append(provider_select_row) - # --- whisper.cpp model --- - whisper_model_label = Gtk.Label(label=f"whisper.cpp {self('o_Model_Path')}") - whisper_model_label.set_xalign(0) - content.append(whisper_model_label) + provider_string_list = Gtk.StringList.new(provider_names) + provider_dropdown = Gtk.DropDown.new(provider_string_list, None) + provider_dropdown.set_hexpand(True) + provider_dropdown.set_halign(Gtk.Align.FILL) + provider_dropdown.set_selected(provider_names.index(selected_provider_name)) + provider_select_row.append(provider_dropdown) - whisper_model_entry = Gtk.Entry() - whisper_model_entry.set_placeholder_text("/home/usr/models/ggml-base.bin") - whisper_model_entry.set_text(str(cfg.get("whisper_cpp_model", "") or "")) - content.append(whisper_model_entry) + add_provider_btn = Gtk.Button(label="+") + self.bind_i18n(add_provider_btn, "tooltip", "o_Add_Provider") + add_provider_btn.set_size_request(34, 34) + provider_select_row.append(add_provider_btn) - # Link - link = Gtk.LinkButton.new_with_label( - "https://openrouter.ai/models?fmt=cards&input_modalities=audio", - "OpenRouter – STT Models Page" - ) - link.set_halign(Gtk.Align.CENTER) - content.append(link) + stop_provider_btn = Gtk.Button(label="⏹") + self.bind_i18n(stop_provider_btn, "tooltip", "o_Stop_Provider") + stop_provider_btn.set_size_request(34, 34) + stop_provider_btn.add_css_class("flat") + provider_select_row.append(stop_provider_btn) + delete_provider_btn = Gtk.Button(label="🗑") + self.bind_i18n(delete_provider_btn, "tooltip", "o_Delete_Provider") + delete_provider_btn.set_size_request(34, 34) + delete_provider_btn.add_css_class("flat") + provider_select_row.append(delete_provider_btn) + + local_provider_name_label = Gtk.Label() + self.bind_i18n(local_provider_name_label, "label", "o_Provider_Name") + local_provider_name_label.set_xalign(0) + content.append(local_provider_name_label) + + local_provider_name_entry = Gtk.Entry() + local_provider_name_entry.set_text(selected_provider_name) + local_provider_name_entry.set_placeholder_text("ollama") + content.append(local_provider_name_entry) + + row_local_enabled = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + + local_enabled_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + local_enabled_box.set_hexpand(True) + + local_enabled_title = Gtk.Label() + self.bind_i18n(local_enabled_title, "label", "o_Enable_Provider") + local_enabled_title.set_xalign(0) + local_enabled_title.set_halign(Gtk.Align.START) + + local_enabled_desc = Gtk.Label() + self.bind_i18n(local_enabled_desc, "label", "o_Enable_Provider_Hint") + local_enabled_desc.set_xalign(0) + local_enabled_desc.set_halign(Gtk.Align.START) + local_enabled_desc.set_wrap(True) + local_enabled_desc.add_css_class("dim-label") + + local_enabled_box.append(local_enabled_title) + local_enabled_box.append(local_enabled_desc) + + local_enabled_switch = Gtk.Switch() + local_enabled_switch.set_active(bool(local_cfg.get("enabled", False))) + local_enabled_switch.set_halign(Gtk.Align.END) + local_enabled_switch.set_valign(Gtk.Align.CENTER) + + row_local_enabled.append(local_enabled_box) + row_local_enabled.append(local_enabled_switch) + content.append(row_local_enabled) + + local_base_url_label = Gtk.Label(label="Base URL") + local_base_url_label.set_xalign(0) + content.append(local_base_url_label) + + local_base_url_entry = Gtk.Entry() + local_base_url_entry.set_text(str(local_cfg.get("base_url", ""))) + local_base_url_entry.set_placeholder_text("http://127.0.0.1:11434") + content.append(local_base_url_entry) + + local_run_startup_label = Gtk.Label() + self.bind_i18n(local_run_startup_label, "label", "o_Run_Startup") + local_run_startup_label.set_xalign(0) + content.append(local_run_startup_label) + + local_run_startup_entry = Gtk.Entry() + local_run_startup_entry.set_text(str(local_cfg.get("run_startup", ""))) + self.bind_i18n(local_run_startup_entry, "placeholder", "o_Run_Startup_Placeholder") + content.append(local_run_startup_entry) + + local_stop_command_label = Gtk.Label() + self.bind_i18n(local_stop_command_label, "label", "o_Stop_Command") + local_stop_command_label.set_xalign(0) + content.append(local_stop_command_label) + local_stop_command_entry = Gtk.Entry() + local_stop_command_entry.set_text(str(local_cfg.get("stop_command", ""))) + self.bind_i18n(local_stop_command_entry, "placeholder", "o_Stop_Command_Placeholder") + content.append(local_stop_command_entry) + + local_system_error_label = Gtk.Label() + self.bind_i18n(local_system_error_label, "label", "o_Custom_Error") + local_system_error_label.set_xalign(0) + content.append(local_system_error_label) + + local_system_error_entry = Gtk.Entry() + local_system_error_entry.set_text(str(local_cfg.get("system_error", ""))) + self.bind_i18n(local_system_error_entry, "placeholder", "o_Custom_Error_Placeholder") + content.append(local_system_error_entry) + + def add_local_param_row(title_text, key, placeholder=""): + lab = Gtk.Label(label=title_text) + lab.set_xalign(0) + content.append(lab) + + entry = Gtk.Entry() + entry.set_text(str(local_cfg.get(key, ""))) + if placeholder: + entry.set_placeholder_text(placeholder) + content.append(entry) + return entry + + local_temperature_entry = add_local_param_row("Temperature", "temperature", "0.7") + local_top_p_entry = add_local_param_row("Top P", "top_p", "0.9") + local_top_k_entry = add_local_param_row("Top K", "top_k", "40") + local_repeat_penalty_entry = add_local_param_row("Repeat Penalty", "repeat_penalty", "1.1") + local_num_ctx_entry = add_local_param_row("Num Ctx", "num_ctx", "4096") + local_num_predict_entry = add_local_param_row("Num Predict", "num_predict", "512") + local_keep_alive_entry = add_local_param_row("Keep Alive", "keep_alive", "5m") + local_system_prompt_entry = add_local_param_row(self("o_System_Prompt"), "system_prompt", "") + + local_link_ollama = Gtk.LinkButton.new_with_label( + "https://docs.ollama.com/api/chat", + "Ollama – Local API" + ) + local_link_ollama.set_halign(Gtk.Align.CENTER) + content.append(local_link_ollama) + + local_link_lmstudio = Gtk.LinkButton.new_with_label( + "https://lmstudio.ai/docs/developer", + "LM Studio – Local API" + ) + local_link_lmstudio.set_halign(Gtk.Align.CENTER) + content.append(local_link_lmstudio) + + local_link_vllm = Gtk.LinkButton.new_with_label( + "https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html", + "vLLM – OpenAI-Compatible API" + ) + local_link_vllm.set_halign(Gtk.Align.CENTER) + content.append(local_link_vllm) + + + + def get_current_form_data(): + return { + "enabled": bool(local_enabled_switch.get_active()), + "base_url": local_base_url_entry.get_text().strip() or "http://127.0.0.1:11434", + "run_startup": local_run_startup_entry.get_text().strip(), + "stop_command": local_stop_command_entry.get_text().strip(), + "system_error": local_system_error_entry.get_text().strip(), + "temperature": local_temperature_entry.get_text().strip(), + "top_p": local_top_p_entry.get_text().strip(), + "top_k": local_top_k_entry.get_text().strip(), + "repeat_penalty": local_repeat_penalty_entry.get_text().strip(), + "num_ctx": local_num_ctx_entry.get_text().strip(), + "num_predict": local_num_predict_entry.get_text().strip(), + "keep_alive": local_keep_alive_entry.get_text().strip(), + "system_prompt": local_system_prompt_entry.get_text().strip(), + } + + def set_form_data(provider_name: str): + cfg_map = merged_provider_cfg(provider_name) + + local_provider_name_entry.set_text(provider_name) + local_enabled_switch.set_active(bool(cfg_map.get("enabled", False))) + local_base_url_entry.set_text(str(cfg_map.get("base_url", ""))) + local_run_startup_entry.set_text(str(cfg_map.get("run_startup", ""))) + local_run_startup_entry.set_placeholder_text(f"Exp: ollama serve") + local_stop_command_entry.set_text(str(cfg_map.get("stop_command", ""))) + local_system_error_entry.set_text(str(cfg_map.get("system_error", ""))) + local_temperature_entry.set_text(str(cfg_map.get("temperature", ""))) + local_top_p_entry.set_text(str(cfg_map.get("top_p", ""))) + local_top_k_entry.set_text(str(cfg_map.get("top_k", ""))) + local_repeat_penalty_entry.set_text(str(cfg_map.get("repeat_penalty", ""))) + local_num_ctx_entry.set_text(str(cfg_map.get("num_ctx", ""))) + local_num_predict_entry.set_text(str(cfg_map.get("num_predict", ""))) + local_keep_alive_entry.set_text(str(cfg_map.get("keep_alive", ""))) + local_system_prompt_entry.set_text(str(cfg_map.get("system_prompt", ""))) + + def write_local_providers_to_config(): + cfg_now = ensure_base_config(self.load_config()) + cfg_now["local_providers"] = dict(local_providers) + self.save_config(cfg_now) + + def refresh_provider_dropdown(selected_name: str | None = None): + nonlocal provider_names, provider_string_list, selected_provider_name + + provider_names = list(local_providers.keys()) + if not provider_names: + local_providers["ollama"] = dict(provider_defaults) + provider_names = ["ollama"] + + provider_string_list = Gtk.StringList.new(provider_names) + provider_dropdown.set_model(provider_string_list) + + if selected_name and selected_name in provider_names: + selected_provider_name = selected_name + provider_dropdown.set_selected(provider_names.index(selected_name)) + else: + selected_provider_name = provider_names[0] + provider_dropdown.set_selected(0) + + set_form_data(selected_provider_name) + + def on_provider_changed(dropdown, _pspec): + nonlocal selected_provider_name + + idx = dropdown.get_selected() + if idx == Gtk.INVALID_LIST_POSITION: + return + + old_name = selected_provider_name + new_name = provider_names[idx] + + if old_name and old_name in local_providers: + local_providers[old_name] = get_current_form_data() + + selected_provider_name = new_name + set_form_data(new_name) + + provider_dropdown.connect("notify::selected", on_provider_changed) + + def on_add_provider_clicked(_btn): + nonlocal selected_provider_name + + if selected_provider_name and selected_provider_name in local_providers: + local_providers[selected_provider_name] = get_current_form_data() + + base_name = "new_provider" + new_name = base_name + i = 1 + while new_name in local_providers: + new_name = f"{base_name}_{i}" + i += 1 + + local_providers[new_name] = dict(provider_defaults) + write_local_providers_to_config() + refresh_provider_dropdown(new_name) + + local_provider_name_entry.grab_focus() + local_provider_name_entry.select_region(0, -1) + + add_provider_btn.connect("clicked", on_add_provider_clicked) + + def on_delete_provider_clicked(_btn): + nonlocal selected_provider_name + + if not selected_provider_name or selected_provider_name not in local_providers: + return + + if len(local_providers) <= 1: + warn = Gtk.Dialog(transient_for=self.win) + warn.set_modal(True) + warn.set_title(self("o_Provider_Cant_Be_Deleted")) + + warn_content = warn.get_content_area() + warn_label = Gtk.Label(label=self("o_Provider_Min_One_Required")) + warn_label.set_wrap(True) + warn_content.append(warn_label) + + warn.add_button(self("o_OK"), Gtk.ResponseType.OK) + warn.connect("response", lambda d, r: d.destroy()) + warn.present() + return + + confirm = Gtk.Dialog(transient_for=self.win) + confirm.set_modal(True) + confirm.set_title(self("o_Delete_Provider")) + + confirm_content = confirm.get_content_area() + confirm_label = Gtk.Label( + label=self("o_Delete_Provider_Confirm", provider=selected_provider_name) + ) + confirm_label.set_wrap(True) + confirm_content.append(confirm_label) + + confirm.add_button(self("o_Cancel"), Gtk.ResponseType.CANCEL) + confirm.add_button(self("o_Delete"), Gtk.ResponseType.OK) + + def on_confirm_response(dlg, response): + nonlocal selected_provider_name + + if response == Gtk.ResponseType.OK: + old_name = selected_provider_name + if old_name in local_providers: + del local_providers[old_name] + + write_local_providers_to_config() + + remaining = list(local_providers.keys()) + next_name = remaining[0] if remaining else "ollama" + refresh_provider_dropdown(next_name) + + dlg.destroy() + + confirm.connect("response", on_confirm_response) + confirm.present() + + delete_provider_btn.connect("clicked", on_delete_provider_clicked) + + def on_stop_provider_clicked(_btn): + nonlocal selected_provider_name + + if not selected_provider_name: + return + + # dialog içindeki henüz kaydedilmemiş form verisini de kullan + current_cfg = get_current_form_data() + self.stop_selected_provider(selected_provider_name, current_cfg) + + stop_provider_btn.connect("clicked", on_stop_provider_clicked) dialog.add_button(self("o_Cancel"), Gtk.ResponseType.CANCEL) dialog.add_button(self("o_Save"), Gtk.ResponseType.OK) - - - def refresh_sensitive_state(*_): - is_online = bool(online_switch.get_active()) - - stt_model_label.set_sensitive(is_online) - stt_model_entry.set_sensitive(is_online) - - whisper_bin_label.set_sensitive(not is_online) - whisper_bin_entry.set_sensitive(not is_online) - whisper_model_label.set_sensitive(not is_online) - whisper_model_entry.set_sensitive(not is_online) - - online_switch.connect("notify::active", refresh_sensitive_state) - refresh_sensitive_state() - def on_response(d, resp): if resp == Gtk.ResponseType.OK: - cfg2 = self.load_config() + cfg2 = ensure_base_config(self.load_config()) + cfg2["force_ui_language"] = bool(response_switch.get_active()) - cfg2["is_mic_online"] = bool(online_switch.get_active()) - cfg2["use_desktop_voice"] = bool(desktop_switch.get_active()) - cfg2["stt_model_online"] = stt_model_entry.get_text().strip() - cfg2["whisper_cpp_bin"] = whisper_bin_entry.get_text().strip() - cfg2["whisper_cpp_model"] = whisper_model_entry.get_text().strip() + cfg2["ask_for_web_search"] = bool(ask_web_switch.get_active()) + + old_selected_name = selected_provider_name + new_provider_name = local_provider_name_entry.get_text().strip() or "ollama" + + if "local_providers" not in cfg2 or not isinstance(cfg2["local_providers"], dict): + cfg2["local_providers"] = {} + + if old_selected_name in local_providers: + local_providers[old_selected_name] = get_current_form_data() + + if new_provider_name != old_selected_name: + if new_provider_name in local_providers and new_provider_name != old_selected_name: + suffix = 1 + candidate = f"{new_provider_name}_{suffix}" + while candidate in local_providers: + suffix += 1 + candidate = f"{new_provider_name}_{suffix}" + new_provider_name = candidate + + local_providers[new_provider_name] = local_providers.pop(old_selected_name) + + cfg2["local_providers"] = dict(local_providers) + self.save_config(cfg2) d.close() @@ -2679,6 +4670,143 @@ class ChatApp(Gtk.Application): dialog.connect("response", on_response) dialog.present() + + def open_rag_settings_dialog(self): + cfg = ensure_base_config(self.load_config()) + rag = cfg["rag_settings"] + + dialog = Gtk.Dialog(transient_for=self.win) + self.bind_i18n(dialog, "title", "o_RAG_Settings") + dialog.set_modal(True) + dialog.set_default_size(520, 520) + + content = dialog.get_content_area() + content.set_spacing(10) + content.set_margin_top(10) + content.set_margin_bottom(10) + content.set_margin_start(10) + content.set_margin_end(10) + + entries = {} + switches = {} + + def add_int_row(label_key, hint_key, setting_key): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.set_hexpand(True) + + left = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + left.set_hexpand(True) + + title = Gtk.Label() + self.bind_i18n(title, "label", label_key) + title.set_xalign(0) + title.set_halign(Gtk.Align.START) + + hint = Gtk.Label() + self.bind_i18n(hint, "label", hint_key) + hint.set_xalign(0) + hint.set_halign(Gtk.Align.START) + hint.set_wrap(True) + hint.add_css_class("dim-label") + + left.append(title) + left.append(hint) + + entry = Gtk.Entry() + entry.set_text(str(rag.get(setting_key, ""))) + entry.set_width_chars(8) + entry.set_max_width_chars(10) + entry.set_halign(Gtk.Align.END) + + row.append(left) + row.append(entry) + content.append(row) + + entries[setting_key] = entry + + def add_bool_row(label_key, hint_key, setting_key): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.set_hexpand(True) + + left = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + left.set_hexpand(True) + + title = Gtk.Label() + self.bind_i18n(title, "label", label_key) + title.set_xalign(0) + title.set_halign(Gtk.Align.START) + + hint = Gtk.Label() + self.bind_i18n(hint, "label", hint_key) + hint.set_xalign(0) + hint.set_halign(Gtk.Align.START) + hint.set_wrap(True) + hint.add_css_class("dim-label") + + left.append(title) + left.append(hint) + + sw = Gtk.Switch() + sw.set_active(bool(rag.get(setting_key, False))) + sw.set_halign(Gtk.Align.END) + sw.set_valign(Gtk.Align.CENTER) + + row.append(left) + row.append(sw) + content.append(row) + + switches[setting_key] = sw + + add_int_row("o_RAG_Recent_Count", "o_RAG_Recent_Count_Hint", "recent_message_count") + add_int_row("o_RAG_Retrieved_Count", "o_RAG_Retrieved_Count_Hint", "retrieved_chunk_count") + add_int_row("o_RAG_Summary_Every", "o_RAG_Summary_Every_Hint", "summary_update_every") + add_int_row("o_RAG_Memory_Chunk_Max", "o_RAG_Memory_Chunk_Max_Hint", "memory_chunk_max_chars") + add_int_row("o_RAG_Summary_Max", "o_RAG_Summary_Max_Hint", "summary_max_chars") + add_int_row("o_RAG_Code_Context_Max", "o_RAG_Code_Context_Max_Hint", "code_context_max_chars") + + sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) + content.append(sep) + + add_bool_row("o_RAG_Use_Summary", "o_RAG_Use_Summary_Hint", "use_summary") + add_bool_row("o_RAG_Use_Recent", "o_RAG_Use_Recent_Hint", "use_recent_messages") + add_bool_row("o_RAG_Use_Retrieval", "o_RAG_Use_Retrieval_Hint", "use_retrieval") + add_bool_row("o_RAG_Use_Code_Context", "o_RAG_Use_Code_Context_Hint", "use_code_context") + add_bool_row("o_RAG_Recent_Attachments", "o_RAG_Recent_Attachments_Hint", "include_recent_attachments") + + dialog.add_button(self("o_Cancel"), Gtk.ResponseType.CANCEL) + dialog.add_button(self("o_Save"), Gtk.ResponseType.OK) + + def safe_int(entry, default, min_v, max_v): + try: + val = int(entry.get_text().strip()) + except Exception: + val = default + return max(min_v, min(max_v, val)) + + def on_response(d, resp): + if resp == Gtk.ResponseType.OK: + cfg2 = ensure_base_config(self.load_config()) + rag2 = dict(cfg2.get("rag_settings", {})) + + rag2["recent_message_count"] = safe_int(entries["recent_message_count"], 10, 0, 50) + rag2["retrieved_chunk_count"] = safe_int(entries["retrieved_chunk_count"], 5, 0, 50) + rag2["summary_update_every"] = safe_int(entries["summary_update_every"], 20, 1, 200) + rag2["memory_chunk_max_chars"] = safe_int(entries["memory_chunk_max_chars"], 1200, 100, 100_000) + rag2["summary_max_chars"] = safe_int(entries["summary_max_chars"], 6000, 100, 100_000) + rag2["code_context_max_chars"] = safe_int(entries["code_context_max_chars"], 8000, 100, 100_000) + + for key, sw in switches.items(): + rag2[key] = bool(sw.get_active()) + + cfg2["rag_settings"] = rag2 + self.save_config(cfg2) + + d.close() + + dialog.connect("response", on_response) + dialog.present() + + def open_language_dialog(self): dialog = Gtk.Dialog(transient_for=self.win) self.bind_i18n(dialog, "title", "o_Language") @@ -2722,8 +4850,8 @@ class ChatApp(Gtk.Application): def _get_chat_bg_image_path(self) -> str: - cfg = self.load_config() - return str(cfg.get("chat_bg_image", "") or "").strip() + cfg = ensure_base_config(self.load_config()) + return str(cfg["chat_bg_image"]).strip() def _save_chat_bg_image_path(self, path: str): cfg = self.load_config() @@ -2735,55 +4863,10 @@ class ChatApp(Gtk.Application): cfg["chat_bg_image"] = "" self.save_config(cfg) - def ensure_color_configs(self): - cfg = self.load_config() - changed = False - - if not isinstance(cfg.get("custom_colors_dark"), dict): - cfg["custom_colors_dark"] = dict(DEFAULT_CUSTOM_COLORS_DARK) - changed = True - - if not isinstance(cfg.get("custom_colors_light"), dict): - cfg["custom_colors_light"] = dict(DEFAULT_CUSTOM_COLORS_LIGHT) - changed = True - - if "chat_bg_image" not in cfg: - cfg["chat_bg_image"] = "" - changed = True - - if "show_usage" not in cfg: - cfg["show_usage"] = True - changed = True - - if changed: - self.save_config(cfg) - def refresh_visual_theme_only(self): self.apply_css() - self.apply_theme(self.load_config().get("dark_mode", True)) - - try: - self.win.queue_draw() - except Exception: - pass - - for w in [ - getattr(self, "win", None), - getattr(self, "sidebar", None), - getattr(self, "scroll", None), - getattr(self, "content_box", None), - getattr(self, "chat_box", None), - getattr(self, "textview", None), - getattr(self, "text_scroll", None), - getattr(self, "preview_row", None), - getattr(self, "preview_hbox", None), - getattr(self, "selection_row", None), - ]: - if w is not None: - try: - w.queue_draw() - except Exception: - pass + cfg = ensure_base_config(self.load_config()) + self.apply_theme(cfg["dark_mode"]) def refresh_entire_ui_theme(self): self.refresh_visual_theme_only() @@ -2796,8 +4879,8 @@ class ChatApp(Gtk.Application): GLib.idle_add(self.scroll_to_bottom, True) def open_personalization_dialog(self): - cfg = self.load_config() - is_dark = bool(cfg.get("dark_mode", True)) + cfg = ensure_base_config(self.load_config()) + is_dark = cfg["dark_mode"] mode_name = "Dark Mode" if is_dark else "Light Mode" dialog = Gtk.Dialog(title=f"{self('o_Personalization')} ({mode_name})",transient_for=self.win) @@ -2860,6 +4943,7 @@ class ChatApp(Gtk.Application): add_row(self("o_Bot_BG"), "bot_bg") add_row(self("o_Bot_Text"), "bot_text") add_row(self("o_Sidebar_Text"), "sidebar_text") + add_row(self("o_Local_Model_Color"), "local_model_highlight") # --- SHOW USAGE SWITCH --- row_usage = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) @@ -2875,7 +4959,7 @@ class ChatApp(Gtk.Application): usage_label_box.append(usage_title) usage_switch = Gtk.Switch() - usage_switch.set_active(bool(cfg.get("show_usage", True))) + usage_switch.set_active(cfg["show_usage"]) usage_switch.set_halign(Gtk.Align.END) usage_switch.set_valign(Gtk.Align.CENTER) @@ -2884,6 +4968,75 @@ class ChatApp(Gtk.Application): content.append(row_usage) + # --- SHOW TOKEN VALUE SWITCH --- + row_show_token_value = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + + show_token_value_label_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + show_token_value_label_box.set_hexpand(True) + + show_token_value_title = Gtk.Label() + self.bind_i18n(show_token_value_title, "label", "o_Use_Token_Value") + show_token_value_title.set_xalign(0) + show_token_value_title.set_halign(Gtk.Align.START) + + show_token_value_label_box.append(show_token_value_title) + + show_token_value_switch = Gtk.Switch() + show_token_value_switch.set_active(bool(cfg.get("show_token_value", False))) + show_token_value_switch.set_halign(Gtk.Align.END) + show_token_value_switch.set_valign(Gtk.Align.CENTER) + + row_show_token_value.append(show_token_value_label_box) + row_show_token_value.append(show_token_value_switch) + content.append(row_show_token_value) + + + # --- TOKEN VALUE ENTRY --- + row_token_value = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + + token_value_left = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + token_value_left.set_hexpand(True) + + token_value_label = Gtk.Label() + self.bind_i18n(token_value_label, "label", "o_Token_Value") + token_value_label.set_xalign(0) + token_value_label.set_halign(Gtk.Align.START) + + token_value_hint = Gtk.Label() + self.bind_i18n(token_value_hint, "label", "o_Token_Value_Hint") + token_value_hint.set_xalign(0) + token_value_hint.set_halign(Gtk.Align.START) + token_value_hint.set_wrap(True) + token_value_hint.add_css_class("dim-label") + + token_value_left.append(token_value_label) + token_value_left.append(token_value_hint) + + token_value_entry = Gtk.Entry() + token_value_entry.set_text(str(cfg.get("token_value", "2.0"))) + token_value_entry.set_placeholder_text("2.0") + token_value_entry.set_width_chars(8) + token_value_entry.set_max_width_chars(10) + token_value_entry.set_halign(Gtk.Align.END) + + row_token_value.append(token_value_left) + row_token_value.append(token_value_entry) + + content.append(row_token_value) + + + def refresh_token_value_sensitive(*_): + active = bool(show_token_value_switch.get_active()) + + token_value_label.set_sensitive(active) + token_value_hint.set_sensitive(active) + token_value_entry.set_sensitive(active) + row_token_value.set_sensitive(active) + + + show_token_value_switch.connect("notify::active", refresh_token_value_sensitive) + refresh_token_value_sensitive() + bg_path_label = Gtk.Label() self.bind_i18n(bg_path_label, "label", "o_Background_Hint") bg_path_label.set_xalign(0) @@ -2930,12 +5083,21 @@ class ChatApp(Gtk.Application): entry.set_text(defaults[key]) bg_path_entry.set_text("") + usage_switch.set_active(True) + show_token_value_switch.set_active(False) + token_value_entry.set_text("2.0") + refresh_token_value_sensitive() + reset_btn.connect("clicked", on_reset) def on_response(d, resp): if resp == Gtk.ResponseType.OK: cfg2 = self.load_config() cfg2["show_usage"] = bool(usage_switch.get_active()) + cfg2["show_token_value"] = bool(show_token_value_switch.get_active()) + + val = token_value_entry.get_text().strip() + cfg2["token_value"] = val if val else "2.0" self.save_config(cfg2) new_colors = {} @@ -2968,7 +5130,12 @@ class ChatApp(Gtk.Application): dialog.set_modal(True) content = dialog.get_content_area() - content.set_spacing(8) + content.set_spacing(10) + content.set_margin_top(10) + content.set_margin_bottom(10) + content.set_margin_start(10) + content.set_margin_end(10) + label = Gtk.Label() self.bind_i18n(label, "label", "o_Response_Style") @@ -2979,8 +5146,8 @@ class ChatApp(Gtk.Application): entry = Gtk.Entry() self.bind_i18n(entry, "placeholder", "o_Response_Style_Placeholder") - cfg = self.load_config() - entry.set_text(cfg.get("response_style", "") or "") + cfg = ensure_base_config(self.load_config()) + entry.set_text(cfg["response_style"]) content.append(entry) @@ -3087,37 +5254,60 @@ class ChatApp(Gtk.Application): def open_key_dialog(self, _button=None): + cfg = ensure_base_config(self.load_config()) + dialog = Gtk.Dialog() dialog.set_transient_for(self.win) dialog.set_modal(True) - self.bind_i18n(dialog, "title", "o_OpenRouter_API_Key") + self.bind_i18n(dialog, "title", "o_OpenRouter-Tavily_API_Key") content = dialog.get_content_area() + content.set_spacing(10) + content.set_margin_top(10) + content.set_margin_bottom(10) + content.set_margin_start(10) + content.set_margin_end(10) + # OpenRouter API label = Gtk.Label() self.bind_i18n(label, "label", "o_OpenRouter_API_Key") label.set_xalign(0) content.append(label) + openrouter_entry = Gtk.Entry() + self.bind_i18n(openrouter_entry, "placeholder", "o_OpenRouter_Placeholder") + openrouter_entry.set_visibility(False) + openrouter_entry.set_text(str(cfg.get("open_router_key", ""))) + content.append(openrouter_entry) + + + # Tavily API + label = Gtk.Label() + self.bind_i18n(label, "label", "o_Tavily_API_Key") + label.set_xalign(0) + content.append(label) + + tavily_entry = Gtk.Entry() + self.bind_i18n(tavily_entry, "placeholder", "o_Tavily_Placeholder") + tavily_entry.set_visibility(False) + tavily_entry.set_text(str(cfg.get("tavily_api_key", ""))) + content.append(tavily_entry) - entry = Gtk.Entry() - self.bind_i18n(entry, "placeholder", "o_OpenRouter_Placeholder") - entry.set_visibility(False) - content.append(entry) # Ctrl+V ile yapıştırmayı garanti et key_controller = Gtk.EventControllerKey() dialog.add_button(self("o_Cancel"), Gtk.ResponseType.CANCEL) dialog.add_button(self("o_Save"), Gtk.ResponseType.OK) - def on_response(dialog, response): if response == Gtk.ResponseType.OK: - new_key = entry.get_text().strip() - if new_key: - cfg = self.load_config() - cfg["open_router_key"] = new_key - self.save_config(cfg) + cfg = ensure_base_config(self.load_config()) + + cfg["open_router_key"] = openrouter_entry.get_text().strip() + cfg["tavily_api_key"] = tavily_entry.get_text().strip() + + self.save_config(cfg) + dialog.destroy() dialog.connect("response", on_response) @@ -3163,7 +5353,7 @@ class ChatApp(Gtk.Application): box.set_margin_start(8) box.set_margin_end(8) - api_btn = Gtk.Button(label=self('o_OpenRouter_API_Key')) + api_btn = Gtk.Button(label=self('o_OpenRouter-Tavily_API_Key')) api_btn.set_halign(Gtk.Align.FILL) api_btn.connect("clicked", lambda *_: (pop.popdown(), self.open_key_dialog(None))) box.append(api_btn) @@ -3180,10 +5370,19 @@ class ChatApp(Gtk.Application): lang_btn.connect("clicked", lambda *_: (pop.popdown(), self.open_language_dialog())) box.append(lang_btn) + rag_settings_btn = Gtk.Button(label=self("o_RAG_Settings")) + rag_settings_btn.connect("clicked", lambda *_: (pop.popdown(), self.open_rag_settings_dialog())) + box.append(rag_settings_btn) + ai_settings_btn = Gtk.Button(label=self('o_AI_Settings')) ai_settings_btn.connect("clicked", lambda *_: (pop.popdown(), self.open_ai_settings_dialog())) box.append(ai_settings_btn) + stt_settings_btn = Gtk.Button(label=self('o_STT_Settings')) + stt_settings_btn.connect("clicked", lambda *_: (pop.popdown(), self.open_stt_settings_dialog())) + box.append(stt_settings_btn) + + pop.set_child(box) pop.popup() @@ -3197,10 +5396,8 @@ class ChatApp(Gtk.Application): break self.chat_list.remove(row) - cfg = self.load_config() - pinned = cfg.get("pinned_chats", []) - if not isinstance(pinned, list): - pinned = [] + cfg = ensure_base_config(self.load_config()) + pinned = cfg["pinned_chats"] files = list(CHAT_DIR.glob("*.json")) @@ -3308,19 +5505,24 @@ class ChatApp(Gtk.Application): if file_path.exists(): file_path.unlink() - cfg = self.load_config() + cfg = ensure_base_config(self.load_config()) - pinned = cfg.get("pinned_chats", []) - if isinstance(pinned, list) and file_path.name in pinned: + pinned = cfg["pinned_chats"] + if file_path.name in pinned: cfg["pinned_chats"] = [x for x in pinned if x != file_path.name] # chat_models cleanup - chat_models = cfg.get("chat_models", {}) - if isinstance(chat_models, dict) and file_path.name in chat_models: + chat_models = cfg["chat_models"] + if 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: + chat_rag_switch = cfg.setdefault("chat_rag_switch", {}) + if file_path.name in chat_rag_switch: + del chat_rag_switch[file_path.name] + cfg["chat_rag_switch"] = chat_rag_switch + + if cfg["last_chat"] == file_path.name: cfg["last_chat"] = "default.json" self.save_config(cfg) @@ -3330,7 +5532,15 @@ class ChatApp(Gtk.Application): if chats: self.current_chat = chats[0] else: - DEFAULT_CHAT.write_text("[]", encoding="utf-8") + DEFAULT_CHAT.write_text( + json.dumps({ + "summary": "", + "messages": [], + "code_context": {}, + "memory_chunks": [] + }, ensure_ascii=False, indent=2), + encoding="utf-8" + ) self.current_chat = DEFAULT_CHAT # active model for new current chat @@ -3401,26 +5611,125 @@ class ChatApp(Gtk.Application): 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 [] + chat_data = self.load_chat_data_ui() + messages = chat_data["messages"] except Exception: return if not (0 <= idx < len(messages)): return - content = (messages[idx].get("content") or "") + content = self.render_message_text(messages[idx]) if not content: return inner = self.extract_copy_block(content) self.copy_to_clipboard(inner if inner is not None else content) + def download_copy_block_content(self, content: str, suggested_name: str = "content.txt"): + content = str(content or "") + + def safe_name(name: str): + name = str(name or "").strip() or "content.txt" + for ch in ["/", "\\", "\n", "\r", "\t"]: + name = name.replace(ch, "_") + if "." not in Path(name).name: + name += ".txt" + return name + + suggested_name = safe_name(suggested_name) + + def do_write(dest_path): + if not dest_path: + return + try: + Path(dest_path).write_text(content, encoding="utf-8") + self._show_info_dialog( + self("o_Download"), + self("o_File_Saved") + ) + except Exception as e: + self._show_info_dialog(self("o_Error"), str(e)) + + try: + dialog = Gtk.FileDialog() + dialog.set_initial_name(suggested_name) + + def on_save(dlg, res): + try: + gfile = dlg.save_finish(res) + dest = gfile.get_path() if gfile else None + except Exception: + return + + if dest: + do_write(dest) + + dialog.save(self.win, None, on_save) + return + + except Exception: + pass + + dest = Path.home() / "Downloads" / suggested_name + do_write(str(dest)) + + def download_generated_file(self, src_path: str): + src = Path(src_path).expanduser() + + if not src.exists() or not src.is_file(): + self._show_info_dialog( + self("o_Error"), + self("o_File_Not_Found") if self("o_File_Not_Found") != "o_File_Not_Found" else "File not found." + ) + return + + def do_copy(dest_path): + if not dest_path: + return + + try: + shutil.copy2(src, dest_path) + self._show_info_dialog( + self("o_Download"), + self("o_File_Saved") + ) + except Exception as e: + self._show_info_dialog( + self("o_Error"), + str(e) + ) + + # Modern GTK save dialog + try: + dialog = Gtk.FileDialog() + dialog.set_initial_name(src.name) + + def on_save(dlg, res): + try: + gfile = dlg.save_finish(res) + dest = gfile.get_path() if gfile else None + except Exception: + return + + if dest: + do_copy(dest) + + dialog.save(self.win, None, on_save) + return + + except Exception: + pass + + # fallback: Downloads içine kopyala + dest = Path.home() / "Downloads" / src.name + do_copy(str(dest)) + def regenerate_by_index(self, idx: int): # 1) Chat'i oku try: - with open(self.current_chat, "r", encoding="utf-8") as f: - messages = json.load(f) or [] + chat_data = self.load_chat_data_ui() + messages = chat_data["messages"] except Exception: return @@ -3431,7 +5740,7 @@ class ChatApp(Gtk.Application): return role = (messages[idx].get("role") or "").strip() - content = (messages[idx].get("content") or "").strip() + content = self.render_message_text(messages[idx]).strip() if not content: return @@ -3546,9 +5855,17 @@ class ChatApp(Gtk.Application): messages.append(new_user) + messages.append({ + "role": "bot", + "content": "", + "streaming": True + }) + + self._streaming_bot_idx = len(messages) - 1 + try: - with open(self.current_chat, "w", encoding="utf-8") as f: - json.dump(messages, f, ensure_ascii=False, indent=2) + chat_data["messages"] = messages + self.save_chat_data_ui(chat_data) except Exception: return @@ -3564,7 +5881,12 @@ class ChatApp(Gtk.Application): self.set_generating_state(True) # 6) AI çağır: zincir referanslarla birlikte - threading.Thread(target=self.call_ai, args=(selected_arg,), daemon=True).start() + chat_path_for_ai = self.current_chat + threading.Thread( + target=self.call_ai, + args=(selected_arg, chat_path_for_ai), + daemon=True + ).start() def expand_ref_chain(self, messages: list, idx: int, seen=None) -> set[int]: if seen is None: @@ -3587,7 +5909,10 @@ class ChatApp(Gtk.Application): 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")) + has_img = bool( + rmsg.get("image") or + (isinstance(rmsg.get("images"), list) and rmsg.get("images")) + ) if not rtext and has_img: rtext = "[image]" @@ -3607,20 +5932,32 @@ class ChatApp(Gtk.Application): if not path.exists(): break i += 1 - - path.write_text("[]", encoding="utf-8") + #Buradan itibaren rag yaziyoruz + path.write_text( + json.dumps({ + "summary": "", + "messages": [], + "code_context": {}, + "memory_chunks": [] + }, ensure_ascii=False, indent=2), + encoding="utf-8" + ) self.current_chat = path + self.loaded_message_start = None + self._loading_older_messages = False - cfg = self.load_config() + cfg = ensure_base_config(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 + cfg["chat_models"][path.name] = { + "id": str(models[0]["id"]), + "local": bool(models[0].get("local", False)) + } + + cfg.setdefault("chat_rag_switch", {}) + cfg["chat_rag_switch"][path.name] = True + self.save_config(cfg) self.active_model = self.get_chat_model(self.current_chat) @@ -3628,6 +5965,7 @@ class ChatApp(Gtk.Application): self.load_chat_list() self.load_chat() self.load_models_list() + self.refresh_context_mode_switch() if not self.sidebar_expanded: self.toggle_sidebar(None) @@ -3642,7 +5980,10 @@ class ChatApp(Gtk.Application): if not chat_path: return + self.clear_closed_chat_memory() self.current_chat = Path(chat_path) + self.loaded_message_start = None + self._loading_older_messages = False self.selected_indexes.clear() self.selection_mode = False @@ -3660,6 +6001,8 @@ class ChatApp(Gtk.Application): self.load_chat() self.load_models_list() + self.refresh_context_mode_switch() + self.refresh_current_generating_ui() self.auto_focus_enabled = True GLib.idle_add(self.force_focus_entry) @@ -3667,17 +6010,79 @@ class ChatApp(Gtk.Application): # ---------------- CHAT LOAD / SELECTION ---------------- def clear_selection(self, *_): - # tüm seçimleri kaldır - self.selected_indexes.clear() + self.auto_scroll_enabled = False - # seçim modunu kapat + old_scroll = None + try: + adj = self.scroll.get_vadjustment() + if adj: + old_scroll = adj.get_value() + except Exception: + pass + + self.selected_indexes.clear() self.selection_mode = False - # label güncelle + checkboxları kaldırmak için chat’i refresh et self.update_selection_label() self.load_chat() + self.restore_scroll_position_later(old_scroll) + + def show_empty_chat_suggestions(self): + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + outer.set_hexpand(True) + outer.set_vexpand(True) + outer.set_halign(Gtk.Align.CENTER) + outer.set_valign(Gtk.Align.CENTER) + outer.add_css_class("suggestion-wrap") + + title = Gtk.Label(label=self("o_Suggestions_Title")) + title.set_halign(Gtk.Align.CENTER) + title.add_css_class("dim-label") + outer.append(title) + + suggestions_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + suggestions_box.set_halign(Gtk.Align.CENTER) + + keys = [ + "o_Suggestion_1", + "o_Suggestion_2", + "o_Suggestion_3", + "o_Suggestion_4", + "o_Suggestion_5", + ] + + for key in keys: + text = self(key).strip() + + # Çeviri yoksa key'in kendisi döner; onu göstermeyelim + if not text or text == key: + continue + + btn = Gtk.Button(label=text) + btn.add_css_class("flat") + btn.add_css_class("suggestion-bubble") + btn.set_halign(Gtk.Align.CENTER) + + self._consume_click(btn) + + def _on_suggestion_clicked(_btn, suggestion=text): + buf = self.textview.get_buffer() + buf.set_text(suggestion) + self.textview.grab_focus() + + btn.connect("clicked", _on_suggestion_clicked) + suggestions_box.append(btn) + + outer.append(suggestions_box) + self.chat_box.append(outer) + def load_chat(self): + alloc = self.chat_box.get_allocation() + + chat_width = alloc.width if alloc.width > 0 else 800 # fallback + + bubble_max_px = int(chat_width * 0.7) # %70 Bot bubbles max width # Typing indicator varsa, önce onu çıkarıp kenarda tutacağız (silmek yok) kept_typing_row = None if getattr(self, "typing_active", False) and hasattr(self, "typing_row"): @@ -3696,30 +6101,93 @@ class ChatApp(Gtk.Application): break self.chat_box.remove(child) - with open(self.current_chat, "r", encoding="utf-8") as f: - messages = json.load(f) or [] + chat_data = self.load_chat_data_ui() + all_messages = chat_data["messages"] - cfg = self.load_config() - show_usage = bool(cfg.get("show_usage", False)) + has_streaming_placeholder = self.chat_has_streaming_placeholder(self.current_chat) + current_key = self._chat_key(self.current_chat) + current_is_streaming = current_key in self.stream_active_chats - for i, msg in enumerate(messages): + if has_streaming_placeholder and not current_is_streaming: + self.typing_active = True + self.typing_row.set_visible(True) + + if not getattr(self, "_typing_timer_active", False): + self._typing_timer_active = True + GLib.timeout_add(500, self.animate_typing) + else: + self.typing_active = False + try: + self.typing_row.set_visible(False) + except Exception: + pass + + total_messages = len(all_messages) + + if self.loaded_message_start is None: + self.loaded_message_start = max( + 0, + total_messages - self.lazy_chat_initial_count + ) + + # Yeni mesaj geldiyse ve kullanıcı aşağıdaysa son tarafa doğru genişlet + if self.loaded_message_start > total_messages: + self.loaded_message_start = max( + 0, + total_messages - self.lazy_chat_initial_count + ) + + messages = all_messages[self.loaded_message_start:] + index_offset = self.loaded_message_start + + has_real_chat = any( + isinstance(m, dict) and m.get("role") in ("user", "bot", "assistant") + for m in all_messages + ) + + if not has_real_chat: + self.show_empty_chat_suggestions() + if self.auto_scroll_enabled: + GLib.idle_add(self.scroll_to_bottom) + return + + cfg = ensure_base_config(self.load_config()) + show_usage = cfg["show_usage"] + show_token_value = cfg["show_token_value"] + + try: + token_value = float(str(cfg.get("token_value", "2.0")).replace(",", ".")) + except Exception: + token_value = 2.0 + + for local_i, msg in enumerate(messages): + if ( + isinstance(msg, dict) + and msg.get("role") in ("bot", "assistant") + and msg.get("streaming") + and not str(msg.get("content") or "").strip() + ): + continue + + i = index_offset + local_i row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - row.set_hexpand(False) + row.set_hexpand(True) bubble = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) bubble.set_hexpand(False) + bubble_wrap = Adw.Clamp() + bubble_wrap.set_maximum_size(bubble_max_px) + bubble_wrap.set_tightening_threshold(bubble_max_px) + bubble_wrap.set_hexpand(False) + bubble_wrap.set_child(bubble) + # Hover shadow (eski özellik) motion = Gtk.EventControllerMotion() motion.connect("enter", lambda c, b=bubble: b.add_css_class("hovered")) motion.connect("leave", lambda c, b=bubble: b.remove_css_class("hovered")) bubble.add_controller(motion) - # 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: @@ -3756,9 +6224,11 @@ class ChatApp(Gtk.Application): outer.append(mini) bubble.append(outer) + has_text_content = bool(str(msg.get("content") or "").strip()) + + if has_text_content or msg.get("i18n_key") or msg.get("error_key") or msg.get("status_key"): + content = self.render_message_text(msg) - if "content" in msg: - content = msg.get("content") or "" title_block = self.extract_title_block(content) copy_block = self.extract_copy_block(content) apply_block = self.extract_apply_block(content) @@ -3773,13 +6243,14 @@ class ChatApp(Gtk.Application): 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 = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) code_container.add_css_class("code-block") + code_container.set_hexpand(True) + code_container.set_halign(Gtk.Align.FILL) - # 🔹 Üst satır: Title solda, Kopyala sağda - top_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + top_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) top_row.set_hexpand(True) + top_row.set_halign(Gtk.Align.FILL) if title_block: title_label = Gtk.Label() @@ -3787,34 +6258,70 @@ class ChatApp(Gtk.Application): title_label.set_markup(f"{safe}") title_label.set_xalign(0) title_label.set_halign(Gtk.Align.START) + title_label.set_hexpand(True) + title_label.set_wrap(True) + title_label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) + title_label.set_max_width_chars(28) top_row.append(title_label) - spacer = Gtk.Box() - spacer.set_hexpand(True) - top_row.append(spacer) - copy_btn = Gtk.Button(label=f"{self('o_Copy')}") copy_btn.add_css_class("flat") copy_btn.add_css_class("code-copy-btn") - copy_btn.set_halign(Gtk.Align.END) copy_btn.set_focusable(False) + copy_btn.set_halign(Gtk.Align.END) + copy_btn.set_hexpand(False) self._consume_click(copy_btn) copy_btn.connect("clicked", lambda b, t=copy_block: self.copy_to_clipboard(t)) - top_row.append(copy_btn) - # 🔹 Kod label + download_btn = Gtk.Button(label="💾") + download_btn.add_css_class("flat") + download_btn.add_css_class("code-copy-btn") + download_btn.set_focusable(False) + download_btn.set_halign(Gtk.Align.END) + download_btn.set_hexpand(False) + self.bind_i18n(download_btn, "tooltip", "o_Download") + + self._consume_click(download_btn) + + suggested_name = "content.txt" + if title_block: + base = title_block.strip().lower() + for ch in ["/", "\\", "\n", "\r", "\t", ":", "*", "?", "\"", "<", ">", "|"]: + base = base.replace(ch, "_") + suggested_name = (base[:40] or "content") + ".txt" + + download_btn.connect( + "clicked", + lambda b, t=copy_block, n=suggested_name: self.download_copy_block_content(t, n) + ) + + copy_actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + copy_actions.set_halign(Gtk.Align.END) + copy_actions.set_hexpand(False) + copy_actions.append(copy_btn) + copy_actions.append(download_btn) + + copy_wrap = Adw.Clamp() + copy_wrap.set_maximum_size(90) + copy_wrap.set_child(copy_actions) + top_row.append(copy_wrap) + code_label = Gtk.Label(label=copy_block) code_label.set_wrap(True) code_label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) code_label.set_xalign(0) + code_label.set_halign(Gtk.Align.FILL) + code_label.set_hexpand(True) code_label.set_width_chars(0) - code_label.set_max_width_chars(60) + code_label.set_max_width_chars(9999) + code_label.set_selectable(True) code_container.append(top_row) code_container.append(code_label) bubble.append(code_container) + else: # 🔹 Normal mesaj show_text = content @@ -3824,22 +6331,31 @@ class ChatApp(Gtk.Application): show_text = apply_summary label = Gtk.Label(label=show_text) + label.set_selectable(True) label.set_wrap(True) label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) label.set_xalign(0) + label.set_halign(Gtk.Align.FILL) label.set_width_chars(0) - label.set_max_width_chars(60) - label.set_hexpand(False) + label.set_max_width_chars(9999) + label.set_hexpand(True) + + if "http://" in show_text or "https://" in show_text: + link_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + link_box.set_halign(Gtk.Align.START) + self.append_text_with_links(link_box, show_text) + bubble.append(link_box) + else: + bubble.append(label) - bubble.append(label) # Eğer bu mesaj streaming placeholder ise, canlı güncelleme bu label'a yazılsın if msg.get("role") != "user" and msg.get("streaming"): self.streaming_label = label self.streaming_row = row - # içerik henüz yoksa boş balonu gösterme - if not show_text.strip(): - row.set_visible(False) + if msg.get("streaming") and not show_text.strip(): + row.set_visible(False) + # Tekli image (legacy) if msg.get("image"): @@ -3868,17 +6384,22 @@ class ChatApp(Gtk.Application): files = msg.get("files") if isinstance(files, list) and files: files_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + for f in files: if not isinstance(f, dict): continue + name = (f.get("name") or Path(str(f.get("path") or "")).name or "").strip() if not name: continue + is_editable = bool(f.get("edit") or f.get("editable")) + chip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) chip.add_css_class("refs-preview") - icon = Gtk.Image.new_from_icon_name("text-x-generic-symbolic") + icon_name = "document-edit-symbolic" if is_editable else "text-x-generic-symbolic" + icon = Gtk.Image.new_from_icon_name(icon_name) chip.append(icon) lab = Gtk.Label(label=name) @@ -3892,6 +6413,16 @@ class ChatApp(Gtk.Application): bubble.append(files_box) + # Tek seçili balonda action butonları (copy + regenerate) + show_actions = (len(self.selected_indexes) == 1 and i in self.selected_indexes) + + + raw_text = self.render_message_text(msg).strip() + short_msg = len(raw_text) <= 24 and "\n" not in raw_text and len(raw_text.split()) <= 4 + # kısa mesajlarda dışta, uzunlarda ortada overlay + use_outside_actions = short_msg + + if msg.get("role") == "user": bubble.add_css_class("user-bubble") @@ -3900,13 +6431,23 @@ class ChatApp(Gtk.Application): elif msg.get("used_refs"): bubble.add_css_class("user-bubble-ref") - row.set_halign(Gtk.Align.END) - bubble.set_halign(Gtk.Align.END) + row.set_halign(Gtk.Align.FILL) + bubble_wrap.set_halign(Gtk.Align.END) + bubble.set_halign(Gtk.Align.FILL) + + bubble_wrap.set_margin_start(0) + else: bubble.add_css_class("bot-bubble") - row.set_halign(Gtk.Align.START) + row.set_halign(Gtk.Align.FILL) + bubble_wrap.set_halign(Gtk.Align.START) + bubble.set_halign(Gtk.Align.FILL) - if i in self.selected_indexes: + bubble_wrap.set_margin_end(0) + + is_selected = i in self.selected_indexes + + if is_selected: bubble.add_css_class("selected") # ---------------- APPLY BUTTONS ---------------- @@ -3942,7 +6483,7 @@ class ChatApp(Gtk.Application): if status == "ok": txt = f"✅ {self('o_File_Changed')}" elif status == "fail": - txt = f"❌ APPLY FAILED: {err}" + txt = self("o_Apply_Failed_Input", error=err) elif status == "cancel": txt = f"⏭️ {self('o_Cancelled')}" else: @@ -3989,6 +6530,7 @@ class ChatApp(Gtk.Application): if p: pp = Path(p) short = f"{pp.parent.name}/{pp.name}" if pp.parent.name else pp.name + if short not in paths: paths.append(short) @@ -4045,6 +6587,140 @@ class ChatApp(Gtk.Application): # parse_err varsa bile input'a basma; sadece buton göstermemek daha doğru # (çünkü her bot mesajında parse_err input’u kirletir) + # ------------------ WEB SEARCH REQUEST BUTTONS ------------------- + + web_req = msg.get("web_search_request") + + if isinstance(web_req, dict): + query = str(web_req.get("query") or "").strip() + status = str(web_req.get("status") or "pending").strip() + + web_area = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + web_area.set_halign(Gtk.Align.START) + + info_label = Gtk.Label(label=self("o_Web_Search_Request_With_Query", query=query)) + info_label.set_xalign(0) + info_label.set_wrap(True) + info_label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) + info_label.set_halign(Gtk.Align.START) + web_area.append(info_label) + + if status == "pending": + btn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + + search_btn = Gtk.Button(label=self("o_Search")) + search_btn.add_css_class("suggested-action") + self._consume_click(search_btn) + search_btn.connect( + "clicked", + lambda _b, idx=i: self.approve_web_search(idx) + ) + + cancel_btn = Gtk.Button(label=self("o_Cancel")) + cancel_btn.add_css_class("flat") + self._consume_click(cancel_btn) + cancel_btn.connect( + "clicked", + lambda _b, idx=i: self.cancel_web_search(idx) + ) + + btn_row.append(search_btn) + btn_row.append(cancel_btn) + web_area.append(btn_row) + + elif status == "searching": + status_label = Gtk.Label(label="Searching...") + status_label.set_xalign(0) + status_label.add_css_class("refs-preview-line") + web_area.append(status_label) + + elif status == "cancelled": + status_label = Gtk.Label(label=f"⏭️ {self('o_Web_Search_Cancelled')}") + status_label.set_xalign(0) + status_label.add_css_class("refs-preview-line") + web_area.append(status_label) + + elif status == "searched": + status_label = Gtk.Label(label=f"✅ {self('o_Web_Search_Searched')}") + status_label.set_xalign(0) + status_label.add_css_class("refs-preview-line") + web_area.append(status_label) + + elif status == "failed": + err = str(web_req.get("err") or "").strip() + txt = ( + f"❌ {self('o_Web_Search_Error_With_Detail', error=err)}" + if err else + f"❌ {self('o_Web_Search_Failed')}" + ) + + status_label = Gtk.Label(label=txt) + status_label.set_xalign(0) + status_label.set_wrap(True) + status_label.add_css_class("refs-preview-line") + web_area.append(status_label) + + elif status == "error": + err = str(web_req.get("err") or "").strip() + status_label = Gtk.Label(label=f"Web search error: {err}") + status_label.set_xalign(0) + status_label.set_wrap(True) + status_label.add_css_class("refs-preview-line") + web_area.append(status_label) + + bubble.append(web_area) + usage_parent = web_area + + # ---------------- GENERATED FILE DOWNLOAD BUTTONS ---------------- + generated_files = msg.get("generated_files") + + if isinstance(generated_files, list) and generated_files: + download_area = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + download_area.set_halign(Gtk.Align.START) + + for gf in generated_files: + if not isinstance(gf, dict): + continue + + path = str(gf.get("path") or "").strip() + name = str(gf.get("name") or Path(path).name).strip() + + if not path: + continue + + p = Path(path) + if not p.exists() or not p.is_file(): + continue + + file_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + file_row.set_halign(Gtk.Align.START) + + name_label = Gtk.Label(label=name) + name_label.set_xalign(0) + name_label.set_halign(Gtk.Align.START) + name_label.set_max_width_chars(36) + name_label.set_ellipsize(Pango.EllipsizeMode.END) + name_label.add_css_class("refs-preview-line") + + download_btn = Gtk.Button(label="💾") + download_btn.add_css_class("flat") + download_btn.set_tooltip_text(name) + download_btn.set_size_request(34, 34) + self._consume_click(download_btn) + download_btn.connect( + "clicked", + lambda _b, src=path: self.download_generated_file(src) + ) + + file_row.append(download_btn) + file_row.append(name_label) + download_area.append(file_row) + + if download_area.get_first_child() is not None: + bubble.append(download_area) + usage_parent = download_area + + # BURASI if apply_block'UN DIŞINDA OLMALI u = msg.get("usage") if show_usage and isinstance(u, dict): @@ -4066,71 +6742,311 @@ class ChatApp(Gtk.Application): usage_label.add_css_class("refs-preview-line") usage_parent.append(usage_label) - # Tek seçili balonda action butonları (copy + regenerate) - show_actions = (len(self.selected_indexes) == 1 and i in self.selected_indexes) + # Token price her zaman en altta olsun + if show_token_value and token_value > 0 and t > 0: + # token_value = 1M token başına ücret + token_price = (t / 1_000_000) * token_value - # bubble + buton alanı (spacer) - content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + price_txt = f"{self('o_Total_Token_Price')}: ${token_price:.6f}" - btn_space = Gtk.Box() - btn_space.set_size_request(130, -1) # buton alanı (70-120 ayarla) + price_label = Gtk.Label(label=price_txt) + price_label.set_xalign(0) + price_label.set_halign(Gtk.Align.START) + price_label.set_wrap(True) + price_label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) + price_label.add_css_class("refs-preview-line") + usage_parent.append(price_label) - if 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) + if use_outside_actions: + outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + outer.set_hexpand(True) + outer.set_halign(Gtk.Align.FILL) - wrapper = Gtk.Overlay() - wrapper.set_child(content) + left_spacer = Gtk.Box() + left_spacer.set_hexpand(True) - if show_actions: - actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + right_spacer = Gtk.Box() + right_spacer.set_hexpand(True) - copy_btn = Gtk.Button(label="📋") - self._consume_click(copy_btn) - copy_btn.connect("clicked", lambda _b, idx=i: self.copy_message_by_index(idx)) + bubble_click = Gtk.GestureClick() + bubble_click.connect("pressed", lambda g, n, x, y, idx=i: self.on_message_clicked(idx)) + bubble.add_controller(bubble_click) - regen_btn = Gtk.Button(label="♻") - self._consume_click(regen_btn) - regen_btn.connect("clicked", lambda _b, idx=i: self.regenerate_by_index(idx)) + small_overlay = Gtk.Overlay() + small_overlay.set_child(bubble_wrap) + small_overlay.set_hexpand(False) + small_overlay.set_vexpand(False) - actions.append(copy_btn) - actions.append(regen_btn) + if show_actions: + actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + actions.set_valign(Gtk.Align.CENTER) + actions.set_hexpand(False) + actions.set_vexpand(False) + + copy_btn = Gtk.Button(label="📋") + copy_btn.add_css_class("flat") + copy_btn.add_css_class("action-center-btn") + copy_btn.set_size_request(28, 28) + copy_btn.set_can_target(True) + self._consume_click(copy_btn) + copy_btn.connect("clicked", lambda _b, idx=i: self.copy_message_by_index(idx)) + + regen_btn = Gtk.Button(label="♻") + regen_btn.add_css_class("flat") + regen_btn.add_css_class("action-center-btn") + regen_btn.set_size_request(28, 28) + regen_btn.set_can_target(True) + self._consume_click(regen_btn) + regen_btn.connect("clicked", lambda _b, idx=i: self.regenerate_by_index(idx)) + + actions.append(copy_btn) + actions.append(regen_btn) + + if msg.get("role") == "user": + pass + else: + actions.set_halign(Gtk.Align.START) + actions.set_valign(Gtk.Align.CENTER) + actions.set_margin_start(2) + actions.set_margin_end(0) - 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) + outer.append(left_spacer) + user_pack = Gtk.Overlay() + + user_line = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + user_line.set_hexpand(False) + user_line.set_vexpand(False) + + # Butonlar için sadece yatay yer ayırır, yüksekliği büyütmez + action_space = Gtk.Box() + action_space.set_size_request(140, 1) + action_space.set_hexpand(False) + action_space.set_vexpand(False) + + user_line.append(action_space) + user_line.append(small_overlay) + + user_pack.set_child(user_line) + user_pack.set_hexpand(False) + user_pack.set_vexpand(False) + + if show_actions: + actions.set_halign(Gtk.Align.START) + actions.set_valign(Gtk.Align.CENTER) + actions.set_margin_start(0) + actions.set_margin_end(0) + + user_pack.add_overlay(actions) + user_pack.set_clip_overlay(actions, False) + + outer.append(user_pack) + else: - actions.set_halign(Gtk.Align.END) # sağ spacer içinde kalsın - actions.set_margin_end(0) + bot_pack = Gtk.Overlay() - wrapper.add_overlay(actions) + bot_line = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + bot_line.set_hexpand(False) + bot_line.set_vexpand(False) + + bot_line.append(small_overlay) + + # Butonlar için sadece yatay alan ayırır, yüksekliği büyütmez + action_space = Gtk.Box() + action_space.set_size_request(140, 1) + action_space.set_hexpand(False) + action_space.set_vexpand(False) + bot_line.append(action_space) + + bot_pack.set_child(bot_line) + bot_pack.set_hexpand(False) + bot_pack.set_vexpand(False) + + if show_actions: + actions.set_halign(Gtk.Align.END) + actions.set_valign(Gtk.Align.CENTER) + actions.set_margin_start(0) + actions.set_margin_end(0) + + bot_pack.add_overlay(actions) + bot_pack.set_clip_overlay(actions, False) + + outer.append(bot_pack) + outer.append(right_spacer) + + row.append(outer) + + else: + bubble_overlay = Gtk.Overlay() + bubble_overlay.set_child(bubble_wrap) + bubble_overlay.set_clip_overlay(bubble_wrap, False) + if is_selected: + bubble.add_css_class("selected") + + bubble_overlay.set_halign(Gtk.Align.END if msg.get("role") == "user" else Gtk.Align.START) + bubble_overlay.set_valign(Gtk.Align.FILL) + bubble_overlay.set_hexpand(False) + bubble_overlay.set_vexpand(False) + + wrapper_click = Gtk.GestureClick() + wrapper_click.connect("pressed", lambda g, n, x, y, idx=i: self.on_message_clicked(idx)) + bubble_overlay.add_controller(wrapper_click) + + if show_actions: + dim = Gtk.Box() + dim.add_css_class("action-dim") + dim.set_halign(Gtk.Align.FILL) + dim.set_valign(Gtk.Align.FILL) + dim.set_hexpand(False) + dim.set_vexpand(False) + + if msg.get("role") == "user": + dim.set_margin_end(10) + else: + dim.set_margin_start(10) + + dim_click = Gtk.GestureClick() + dim_click.connect("pressed", lambda *_: self.clear_selection()) + dim.add_controller(dim_click) + + bubble_overlay.add_overlay(dim) + + actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + actions.set_halign(Gtk.Align.CENTER) + actions.set_valign(Gtk.Align.CENTER) + + copy_btn = Gtk.Button(label="📋") + copy_btn.add_css_class("flat") + copy_btn.add_css_class("action-center-btn") + copy_btn.set_size_request(36, 36) + self._consume_click(copy_btn) + copy_btn.connect("clicked", lambda _b, idx=i: self.copy_message_by_index(idx)) + + regen_btn = Gtk.Button(label="♻") + regen_btn.add_css_class("flat") + regen_btn.add_css_class("action-center-btn") + regen_btn.set_size_request(36, 36) + self._consume_click(regen_btn) + regen_btn.connect("clicked", lambda _b, idx=i: self.regenerate_by_index(idx)) + + actions.append(copy_btn) + actions.append(regen_btn) + bubble_overlay.add_overlay(actions) + + if msg.get("role") == "user": + long_outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + long_outer.set_hexpand(True) + long_outer.set_halign(Gtk.Align.FILL) + + left_spacer = Gtk.Box() + left_spacer.set_hexpand(True) + + long_outer.append(left_spacer) + long_outer.append(bubble_overlay) + + row.append(long_outer) + else: + long_outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + long_outer.set_hexpand(True) + long_outer.set_halign(Gtk.Align.FILL) + + right_spacer = Gtk.Box() + right_spacer.set_hexpand(True) + + long_outer.append(bubble_overlay) + long_outer.append(right_spacer) + + row.append(long_outer) - row.append(wrapper) self.chat_box.append(row) - # AI düşünüyorsa typing satırını en alta geri ekle - if getattr(self, "typing_active", False): - try: - if self.typing_row.get_parent() is not self.content_box: - parent = self.typing_row.get_parent() - if parent is not None: - parent.remove(self.typing_row) - self.content_box.append(self.typing_row) - except Exception: - pass GLib.idle_add(self.scroll_to_bottom) + def load_older_messages(self): + if getattr(self, "_loading_older_messages", False): + return + + try: + chat_data = self.load_chat_data_ui() + total = len(chat_data["messages"]) + + if total <= 0: + return + + old_start = self.loaded_message_start + + if old_start is None: + old_start = max(0, total - self.lazy_chat_initial_count) + + if old_start <= 0: + return + + self._loading_older_messages = True + self.auto_scroll_enabled = False + + adj = self.scroll.get_vadjustment() + old_upper = adj.get_upper() if adj else 0 + old_value = adj.get_value() if adj else 0 + + self.loaded_message_start = max( + 0, + old_start - self.lazy_chat_batch_count + ) + + self.load_chat() + + def restore_after_prepend(): + try: + adj2 = self.scroll.get_vadjustment() + if adj2: + new_upper = adj2.get_upper() + delta = new_upper - old_upper + adj2.set_value(old_value + delta) + except Exception: + pass + + self._loading_older_messages = False + return False + + GLib.idle_add(restore_after_prepend) + GLib.timeout_add(60, restore_after_prepend) + + except Exception: + self._loading_older_messages = False + + def restore_scroll_position_later(self, old_scroll): + if old_scroll is None: + return + + def restore_scroll(): + try: + adj = self.scroll.get_vadjustment() + if adj: + max_val = max(0, adj.get_upper() - adj.get_page_size()) + adj.set_value(min(old_scroll, max_val)) + except Exception: + pass + return False + + GLib.idle_add(restore_scroll) + GLib.timeout_add(30, restore_scroll) + GLib.timeout_add(80, restore_scroll) + GLib.timeout_add(160, restore_scroll) + def on_message_clicked(self, index): self.auto_scroll_enabled = False + + # Mevcut scroll pozisyonunu koru + old_scroll = None + try: + adj = self.scroll.get_vadjustment() + if adj: + old_scroll = adj.get_value() + except Exception: + pass + if not self.selection_mode: self.selection_mode = True self.selected_indexes.add(index) @@ -4146,6 +7062,9 @@ class ChatApp(Gtk.Application): self.update_selection_label() self.load_chat() + # load_chat sonrası GTK focus/selectable label yüzünden zıplarsa geri al + self.restore_scroll_position_later(old_scroll) + def update_selection_label(self): if not self.selected_indexes: self.selection_label.set_text("") @@ -4164,9 +7083,13 @@ class ChatApp(Gtk.Application): def _sync_scroll_btn_margin(self): try: - h = self.preview_row.get_allocated_height() if self.preview_row.get_visible() else 0 - # input + padding için kaba bir ek - self.scroll_btn.set_margin_bottom(120 + h) + preview_h = self.preview_row.get_allocated_height() if self.preview_row.get_visible() else 0 + input_h = self.input_card.get_allocated_height() if getattr(self, "input_card", None) else 0 + + if input_h <= 0: + input_h = 80 + + self.scroll_btn.set_margin_bottom(input_h + preview_h + 20) except Exception: pass return False @@ -4185,10 +7108,16 @@ class ChatApp(Gtk.Application): self.scroll_btn.set_visible(False) def on_scroll_changed(self, adjustment): + value = adjustment.get_value() + + # Yukarı yaklaşınca eski mesajları yükle + if value < 300: + GLib.idle_add(self.load_older_messages) + # en altta mı kontrol et - at_bottom = adjustment.get_value() >= ( - adjustment.get_upper() - adjustment.get_page_size() - 800 - ) + at_bottom = value >= ( + adjustment.get_upper() - adjustment.get_page_size() - 800 + ) if at_bottom: self.scroll_btn.set_visible(False) @@ -4236,41 +7165,9 @@ class ChatApp(Gtk.Application): - def ensure_audio_input_config(self): - cfg = self.load_config() - changed = False - - if "force_ui_language" not in cfg: - cfg["force_ui_language"] = False - changed = True - - if "is_mic_online" not in cfg: - cfg["is_mic_online"] = True - changed = True - - if "use_desktop_voice" not in cfg: - cfg["use_desktop_voice"] = False - changed = True - - if "stt_model_online" not in cfg: - cfg["stt_model_online"] = "openai/gpt-audio-mini" - changed = True - - if "whisper_cpp_bin" not in cfg: - cfg["whisper_cpp_bin"] = "/home/$USER/whisper.cpp/build/bin/whisper-cli" - changed = True - - if "whisper_cpp_model" not in cfg: - cfg["whisper_cpp_model"] = "/home/$USER/.local/share/whisper/ggml-tiny.bin" - changed = True - - - if changed: - self.save_config(cfg) - def _get_use_desktop_voice(self) -> bool: - cfg = self.load_config() - return bool(cfg.get("use_desktop_voice", False)) + cfg = ensure_base_config(self.load_config()) + return cfg["use_desktop_voice"] def _is_monitor_like_name(self, name: str) -> bool: s = str(name or "").strip().lower() @@ -4441,12 +7338,20 @@ class ChatApp(Gtk.Application): if use_desktop_voice: target = self._detect_pw_desktop_target() if not target: - self.handle_ai_error("Masaüstü sesi için monitor source bulunamadı.") + self.handle_ai_error({ + "role": "bot", + "error_key": "o_Desktop_Audio_Monitor_Not_Found", + "error_params": {} + }) return else: target = self._detect_pw_mic_target() if not target: - self.handle_ai_error("Mikrofon kaynağı bulunamadı.") + self.handle_ai_error({ + "role": "bot", + "error_key": "o_Microphone_Source_Not_Found", + "error_params": {} + }) return cmd = [pw, "--rate", "16000", "--channels", "1", "--target", str(target), str(self._last_wav)] @@ -4456,18 +7361,30 @@ class ChatApp(Gtk.Application): # Burada mikrofon için normal davranıyoruz. # Desktop sesi gerekiyorsa PipeWire/pw-record tercih edilmeli. if use_desktop_voice: - self.handle_ai_error("Masaüstü sesi kaydı için pw-record gerekli.") + self.handle_ai_error({ + "role": "bot", + "error_key": "o_Desktop_Audio_Requires_PWRecord", + "error_params": {} + }) return device = self._detect_arecord_mic_device() if not device: - self.handle_ai_error("Mikrofon cihazı bulunamadı.") + self.handle_ai_error({ + "role": "bot", + "error_key": "o_Microphone_Device_Not_Found", + "error_params": {} + }) return cmd = [ar, "-D", str(device), "-f", "S16_LE", "-r", "16000", "-c", "1", str(self._last_wav)] else: - self.handle_ai_error("pw-record veya arecord bulunamadı.") + self.handle_ai_error({ + "role": "bot", + "error_key": "o_Audio_Recorder_Not_Found", + "error_params": {} + }) return try: @@ -4478,11 +7395,17 @@ class ChatApp(Gtk.Application): ) except Exception as e: self._voice_proc = None - self.handle_ai_error(f"Ses kaydı başlatılamadı: {e}") + self.handle_ai_error({ + "role": "bot", + "error_key": "o_Audio_Record_Start_Failed", + "error_params": { + "error": str(e) + } + }) return self.mic_btn.set_label("⏹") - self.mic_btn.set_tooltip_text("Kaydı durdur") + self.bind_i18n(self.mic_btn, "tooltip", "o_Stop_Recording") def _normalize_stt_text(self, text: str) -> str: t = (text or "").strip() @@ -4584,8 +7507,8 @@ class ChatApp(Gtk.Application): if not wav or not Path(wav).exists(): return - cfg = self.load_config() - is_online = bool(cfg.get("is_mic_online", True)) + cfg = ensure_base_config(self.load_config()) + is_online = cfg["is_mic_online"] try: if is_online: @@ -4593,14 +7516,30 @@ class ChatApp(Gtk.Application): else: text = self._stt_offline_whisper_cpp(wav, cfg) except Exception as e: - GLib.idle_add(self.handle_ai_error, f"STT hata: {e}") + GLib.idle_add( + self.handle_ai_error, + { + "role": "bot", + "error_key": "o_STT_Error", + "error_params": { + "error": str(e) + } + } + ) return print("STT MODE:", "online" if is_online else "offline") print("STT RAW TEXT:", repr(text)) if text == "__NO_SPEECH__": - GLib.idle_add(self.handle_ai_error, "Ses algılanamadı.") + GLib.idle_add( + self.handle_ai_error, + { + "role": "bot", + "error_key": "o_No_Speech_Detected", + "error_params": {} + } + ) return if text: @@ -4611,7 +7550,7 @@ class ChatApp(Gtk.Application): key = (cfg.get("open_router_key") or "").strip() if not key: - raise RuntimeError("open_router_key yok") + raise RuntimeError(self("o_OpenRouter_STT_Key_Missing")) model = (cfg.get("stt_model_online") or "openai/gpt-audio-mini").strip() @@ -4662,9 +7601,9 @@ class ChatApp(Gtk.Application): 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}") + raise RuntimeError(self("o_Whisper_Bin_Not_Found", path=bin_path)) if not model_path or not Path(model_path).exists(): - raise RuntimeError(f"whisper_cpp_model bulunamadı: {model_path}") + raise RuntimeError(self("o_Whisper_Model_Not_Found", path=model_path)) outdir = Path(tempfile.gettempdir()) / "capture-ai-stt" outdir.mkdir(parents=True, exist_ok=True) @@ -4681,7 +7620,7 @@ class ChatApp(Gtk.Application): 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]) + raise RuntimeError((p.stderr or p.stdout or self("o_Whisper_Failed"))[:800]) txt = Path(str(outbase) + ".txt") if not txt.exists(): @@ -4696,13 +7635,30 @@ class ChatApp(Gtk.Application): def apply_failed_to_input(self, err: str): if not err: - err = "unknown error" + err = self("o_Unknown_Error") buf = self.textview.get_buffer() # input'u temizle ve hata bas (istersen temizleme, sadece append de yapabilirsin) - buf.set_text(f"❌ APPLY FAILED: {err}") + buf.set_text(self("o_Apply_Failed_Input", error=err)) self.textview.grab_focus() + def is_file_editable(self, op): + op = op or {} + + # 1. AI explicit flag + if "editable" in op: + return bool(op["editable"]) + + # 2. patch / diff varsa editable say + if op.get("diff") or op.get("patch"): + return True + + # 3. uzantıya göre fallback + p = str(op.get("path") or "").lower() + editable_exts = (".py", ".js", ".ts", ".json", ".cpp", ".c", ".h", ".css", ".html") + + return p.endswith(editable_exts) + def _apply_edit_op(self, file_path: str, find: str, mode: str, text: str) -> tuple[bool, str]: """ mode: "before" | "after" | "replace" @@ -4713,13 +7669,13 @@ class ChatApp(Gtk.Application): try: p = Path(file_path) if not p.exists(): - return False, f"file not found: {p}" + return False, self("o_Apply_File_Not_Found", path=p) original = p.read_text(encoding="utf-8", errors="ignore") idx = original.find(find) if idx < 0: - return False, f"find failed: anchor not found in {p.name}" + return False, self("o_Apply_Find_Failed", name=p.name) if mode == "before": new_content = original[:idx] + text + original[idx:] @@ -4728,15 +7684,15 @@ class ChatApp(Gtk.Application): elif mode == "replace": new_content = original.replace(find, text, 1) # sadece ilk eşleşme else: - return False, f"unknown mode: {mode}" + return False, self("o_Apply_Unknown_Mode", mode=mode) if new_content == original: - return False, "no changes produced (maybe identical content?)" + return False, self("o_Apply_No_Changes") p.write_text(new_content, encoding="utf-8") return True, "" except Exception as e: - return False, f"exception: {e}" + return False, self("o_Apply_Exception", error=e) def apply_ops_yes(self, ops) -> tuple[bool, str]: """ @@ -4744,7 +7700,7 @@ class ChatApp(Gtk.Application): return: (ok, err) """ if not isinstance(ops, list) or not ops: - return False, "no ops to apply" + return False, self("o_Apply_No_Ops") for op in ops: path = str(op.get("path") or "").strip() @@ -4757,14 +7713,11 @@ class ChatApp(Gtk.Application): 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 [] + chat_data = self.load_chat_data_ui() + messages = chat_data["messages"] except Exception: return @@ -4777,17 +7730,114 @@ class ChatApp(Gtk.Application): } try: - with open(self.current_chat, "w", encoding="utf-8") as f: - json.dump(messages, f, ensure_ascii=False, indent=2) + chat_data["messages"] = messages + self.save_chat_data_ui(chat_data) except Exception: return + # ---------------- ASK WEB SEARCH ----------------- + + def _set_web_search_status(self, msg_index: int, status: str, err: str = ""): + try: + chat_data = self.load_chat_data_ui() + messages = chat_data["messages"] + except Exception: + return + + if not (0 <= msg_index < len(messages)): + return + + req = messages[msg_index].get("web_search_request") + + if not isinstance(req, dict): + return + + req["status"] = status + req["err"] = (err or "").strip() + messages[msg_index]["web_search_request"] = req + + try: + chat_data["messages"] = messages + self.save_chat_data_ui(chat_data) + except Exception: + return + + def mark_latest_web_search_done(self, chat_path, ok: bool, err: str = ""): + try: + chat_data = self.load_chat_data_ui(chat_path) + messages = chat_data["messages"] + + for msg in reversed(messages): + req = msg.get("web_search_request") if isinstance(msg, dict) else None + + if isinstance(req, dict) and req.get("status") == "searching": + req["status"] = "searched" if ok else "failed" + req["err"] = str(err or "").strip() + msg["web_search_request"] = req + break + + chat_data["messages"] = messages + self.save_chat_data_ui(chat_data, chat_path) + + if Path(self.current_chat).resolve() == Path(chat_path).resolve(): + self.load_chat() + + except Exception: + pass + + return False + + def approve_web_search(self, msg_index: int): + try: + chat_data = self.load_chat_data_ui() + messages = chat_data["messages"] + except Exception: + return + + if not (0 <= msg_index < len(messages)): + return + + msg = messages[msg_index] + req = msg.get("web_search_request") + + if not isinstance(req, dict): + return + + query = str(req.get("query") or "").strip() + + if not query: + self._set_web_search_status(msg_index, "error", "Empty search query.") + self.load_chat() + return + + self._set_web_search_status(msg_index, "searching") + self.load_chat() + + chat_path_for_ai = Path(self.current_chat) + + selected_arg = "" + if self.selected_indexes: + selected_arg = ",".join(map(str, sorted(self.selected_indexes))) + + threading.Thread( + target=self.call_ai, + args=(selected_arg, chat_path_for_ai, query), + daemon=True + ).start() + + def cancel_web_search(self, msg_index: int): + self._set_web_search_status(msg_index, "cancelled") + self.load_chat() + # ---------------- STOP GENERATE ----------------- - def stop_ai_generation(self): - self.ai_stop_requested = True + def stop_ai_generation(self, chat_path=None): + chat_path = Path(chat_path or self.current_chat) + key = self._chat_key(chat_path) - proc = getattr(self, "ai_proc", None) + self.ai_stop_requested_chats.add(key) + + proc = self.ai_procs.get(key) if proc is not None: try: proc.terminate() @@ -4797,35 +7847,32 @@ class ChatApp(Gtk.Application): except Exception: pass - self.typing_active = False - self.stream_active = False + self.stream_active_chats.discard(key) + self.stream_ui_ready_chats.discard(key) + + if Path(self.current_chat).resolve() == chat_path.resolve(): + self.typing_active = False + try: + self.typing_row.set_visible(False) + except Exception: + pass + + self.set_generating_state(False, chat_path) try: - self.typing_row.set_visible(False) - except Exception: - pass - - self.set_generating_state(False) - - # Eğer streaming placeholder boş kaldıysa temizle - try: - with open(self.current_chat, "r", encoding="utf-8") as f: - messages = json.load(f) or [] + chat_data = self.load_chat_data_ui(chat_path) + messages = chat_data["messages"] changed = False if messages and isinstance(messages[-1], dict): last = messages[-1] - - # Son mesaj boş streaming bot placeholder ise sil if last.get("role") in ("bot", "assistant") and last.get("streaming"): content = (last.get("content") or "").strip() - if not content: messages.pop() changed = True - # Cancel bilgisini son user mesajına yaz for j in range(len(messages) - 1, -1, -1): msg = messages[j] if msg.get("role") == "user": @@ -4834,28 +7881,56 @@ class ChatApp(Gtk.Application): break if changed: - with open(self.current_chat, "w", encoding="utf-8") as f: - json.dump(messages, f, ensure_ascii=False, indent=2) + chat_data["messages"] = messages + self.save_chat_data_ui(chat_data, chat_path) except Exception: pass - self.load_chat() - self.streaming_row = None - self.streaming_label = None - self.scroll_to_bottom(force=True) + self.ai_procs.pop(key, None) + + if Path(self.current_chat).resolve() == chat_path.resolve(): + self.load_chat() + self.streaming_row = None + self.streaming_label = None + self.scroll_to_bottom(force=True) def on_send_button_clicked(self, *_): - if getattr(self, "is_generating", False): - self.stop_ai_generation() + if self.is_chat_generating(self.current_chat): + self.stop_ai_generation(self.current_chat) else: self.send_message(None) - def set_generating_state(self, generating: bool): - self.is_generating = bool(generating) + def _chat_key(self, chat_path=None): + return str(Path(chat_path or self.current_chat).resolve()) + + def is_chat_generating(self, chat_path=None) -> bool: + return self._chat_key(chat_path) in self.generating_chats + + def refresh_current_generating_ui(self): + self.set_generating_state( + self.is_chat_generating(self.current_chat), + self.current_chat + ) + + def set_generating_state(self, generating: bool, chat_path=None): + chat_path = Path(chat_path or self.current_chat) + key = self._chat_key(chat_path) + + if generating: + self.generating_chats.add(key) + else: + self.generating_chats.discard(key) + + # Eski kodlarla uyumluluk için: + self.is_generating = self.is_chat_generating(self.current_chat) + + # Sadece ekrandaki aktif chat için send butonunu değiştir + if Path(self.current_chat).resolve() != chat_path.resolve(): + return if hasattr(self, "send_btn") and self.send_btn: - if self.is_generating: + if self.is_chat_generating(chat_path): self.send_btn.set_label("☐") self.bind_i18n(self.send_btn, "tooltip", "o_Stop_Button") else: @@ -4871,8 +7946,13 @@ class ChatApp(Gtk.Application): if not message: return - with open(self.current_chat, "r", encoding="utf-8") as f: - messages = json.load(f) or [] + chat_path_for_ai = Path(self.current_chat) + + if self.is_chat_generating(chat_path_for_ai): + return + + chat_data = self.load_chat_data_ui() + messages = chat_data["messages"] new_message = {"role": "user", "content": message} @@ -4939,13 +8019,20 @@ class ChatApp(Gtk.Application): new_message["refs_groups"] = groups + chat_data = self.load_chat_data_ui() + messages = chat_data["messages"] + messages.append(new_message) - # Streaming için placeholder bot mesajı ekle (UI bu balonu canlı günceller) messages.append({"role": "bot", "content": "", "streaming": True}) + + chat_data["messages"] = messages + self.save_chat_data_ui(chat_data) + + # Streaming için placeholder bot mesajı ekle (UI bu balonu canlı günceller) self._streaming_bot_idx = len(messages) - 1 - with open(self.current_chat, "w", encoding="utf-8") as f: - json.dump(messages, f, ensure_ascii=False, indent=2) + chat_data["messages"] = messages + self.save_chat_data_ui(chat_data) buffer.set_text("") # preview temizle @@ -4965,12 +8052,20 @@ class ChatApp(Gtk.Application): self.update_selection_label() self.load_chat() - self.show_typing_indicator() + self.show_typing_indicator(chat_path_for_ai) - self.ai_stop_requested = False - self.set_generating_state(True) + key = self._chat_key(chat_path_for_ai) - threading.Thread(target=self.call_ai, args=(selected_arg,), daemon=True).start() + self.ai_stop_requested_chats.discard(key) + self.set_generating_state(True, chat_path_for_ai) + self.stream_ui_ready_chats.discard(key) + self.stream_active_chats.discard(key) + + threading.Thread( + target=self.call_ai, + args=(selected_arg, chat_path_for_ai), + daemon=True + ).start() def _consume_click(self, widget: Gtk.Widget): """ @@ -5095,36 +8190,23 @@ class ChatApp(Gtk.Application): 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)}' + mode_text = self(f"o_Apply_Mode_{mode}") - def update_stream_text(self, text: str): - text = fix_mojibake(text) - self.stream_active = True + return f"{mode_text}: {self._short(find)} -> {self._short(text)}" + def _load_scaled_texture(self, image_path: str, width: int, height: int): try: - self.typing_row.set_visible(False) - except Exception: - pass - - lab = getattr(self, "streaming_label", None) - row = getattr(self, "streaming_row", None) - - if lab is not None: - if row is not None and not row.get_visible(): - row.set_visible(True) - - current = lab.get_text() or "" - lab.set_text(current + text) - else: - try: - self.typing_label.set_text(text) - self.typing_row.set_visible(True) - except Exception: - pass - - self.scroll_to_bottom(force=True) + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + image_path, + max(1, int(width)), + max(1, int(height)), + True + ) + return Gdk.Texture.new_for_pixbuf(pixbuf) + except Exception as e: + print("scaled texture load error:", e) + return None def build_zoomable_image_widget(self, image_path: str, width: int, height: int): p = Path(str(image_path)) @@ -5137,11 +8219,13 @@ class ChatApp(Gtk.Application): pic.set_size_request(width, height) try: - texture = Gdk.Texture.new_from_filename(str(p)) + texture = self._load_scaled_texture(str(p), width, height) + if texture is None: + raise RuntimeError("texture is None") pic.set_paintable(texture) except Exception as e: print("image preview load error:", e) - fallback = Gtk.Label(label="Image") + fallback = Gtk.Label(label=self("o_Image")) fallback.set_size_request(width, height) fallback.set_wrap(True) fallback.set_xalign(0.5) @@ -5217,7 +8301,9 @@ class ChatApp(Gtk.Application): pic.set_paintable(texture) except Exception as e: print("image viewer load error:", e) - fallback = Gtk.Label(label=f"Image could not be loaded:\n{p.name}") + fallback = Gtk.Label( + label=self("o_Image_Could_Not_Be_Loaded", path=p.name) + ) fallback.set_wrap(True) fallback.set_xalign(0.5) fallback.set_yalign(0.5) @@ -5249,11 +8335,26 @@ class ChatApp(Gtk.Application): return clipboard = display.get_clipboard() clipboard.set(text) + + def call_ai(self, selected_arg="", chat_path_for_ai=None, approved_web_search_query=None): + chat_path_for_ai = Path(chat_path_for_ai or self.current_chat) + context_mode = self.get_context_mode(chat_path_for_ai) - def call_ai(self, selected_arg): - cmd = [sys.executable, "-u", AI_SCRIPT, str(self.current_chat)] - if selected_arg: - cmd.append(selected_arg) + cmd = [ + sys.executable, + "-u", + AI_SCRIPT, + str(chat_path_for_ai), + selected_arg or "", + "--context-mode", + context_mode, + ] + + if approved_web_search_query: + cmd += [ + "--approved-web-search-query", + str(approved_web_search_query) + ] try: env = os.environ.copy() @@ -5269,7 +8370,8 @@ class ChatApp(Gtk.Application): text=False, env=env ) - self.ai_proc = proc + key = self._chat_key(chat_path_for_ai) + self.ai_procs[key] = proc if proc.stdout is None or proc.stderr is None: raise RuntimeError("Popen stdout/stderr PIPE değil (None geldi)") @@ -5278,41 +8380,85 @@ class ChatApp(Gtk.Application): decoder = codecs.getincrementaldecoder("utf-8")("replace") partial = "" - last_len = 0 - last_push = 0 stderr_chunks = [] json_mode = False out_fd = proc.stdout.fileno() err_fd = proc.stderr.fileno() + stream_buffer = [] + stream_lock = threading.Lock() + stream_timer_active = True + stream_failed = False + + STREAM_FLUSH_MS = 10 # 60-120 arası iyi. Daha yavaş istersen 120 yap. + STREAM_CHARS_PER_FLUSH = 8 + + def flush_stream_buffer(): + nonlocal stream_timer_active + + if key in self.ai_stop_requested_chats: + return False + + if (not stream_timer_active) or stream_failed: + return False + + if json_mode: + return True + + with stream_lock: + pending = "".join(stream_buffer) + + if not pending: + return True + + chunk = pending[:STREAM_CHARS_PER_FLUSH] + rest = pending[STREAM_CHARS_PER_FLUSH:] + + stream_buffer.clear() + + if rest: + stream_buffer.append(rest) + + if chunk: + GLib.idle_add( + self.update_stream_text, + chunk, + chat_path_for_ai + ) + + return True + + GLib.timeout_add(STREAM_FLUSH_MS, flush_stream_buffer) + # proc çalıştığı sürece stdout/stderr hazır oldukça oku while True: rlist, _, _ = select.select([out_fd, err_fd], [], [], 0.1) if out_fd in rlist: data = os.read(out_fd, 4096) + if data: text = decoder.decode(data) + if text: partial += text - # Gelen çıktı JSON gibi mi? stripped = partial.lstrip() - if stripped.startswith("{") or stripped.startswith("["): + + # JSON/file output ise stream göstermiyoruz. + # Çünkü file_create / generated_files gibi çıktılar UI'de parça parça görünmemeli. + if ( + stripped.startswith('{"type":') + or stripped.startswith('{"content":') + or stripped.startswith('{"generated_files":') + or stripped.startswith('file_create') + ): json_mode = True - # daha sık güncelle (stream hissi) - now = GLib.get_monotonic_time() # mikro-saniye - if (now - last_push) > 8_000: # 8ms - last_push = now - new_part = partial[last_len:] - last_len = len(partial) - - # Sadece düz metinse ekrana akıt - if new_part and not json_mode: - GLib.idle_add(self.update_stream_text, new_part) - + if not json_mode: + with stream_lock: + stream_buffer.append(text) if err_fd in rlist: err_data = os.read(err_fd, 4096) @@ -5324,37 +8470,113 @@ class ChatApp(Gtk.Application): # decoder tail tail = decoder.decode(b"", final=True) + if tail: partial += tail - new_part = partial[last_len:] - last_len = len(partial) - if new_part and not json_mode: - GLib.idle_add(self.update_stream_text, new_part) + if not json_mode: + with stream_lock: + stream_buffer.append(tail) + + # Son kalan buffer'ı hemen bas + if not json_mode: + with stream_lock: + if stream_buffer: + final_chunk = "".join(stream_buffer) + stream_buffer.clear() + else: + final_chunk = "" + + if final_chunk: + GLib.idle_add( + self.update_stream_text, + final_chunk, + chat_path_for_ai + ) + def stop_stream_when_empty(): + nonlocal stream_timer_active + + with stream_lock: + empty = not bool(stream_buffer) + + if empty: + stream_timer_active = False + return False + + return True + + GLib.timeout_add(STREAM_FLUSH_MS, stop_stream_when_empty) proc.wait() rc = proc.returncode - self.ai_proc = None + self.ai_procs.pop(key, None) stderr_text = "".join(stderr_chunks).strip() - if self.ai_stop_requested: - GLib.idle_add(self.after_ai_response) + if key in self.ai_stop_requested_chats: + stream_failed = True + stream_timer_active = False + + with stream_lock: + stream_buffer.clear() + + GLib.idle_add(self.after_ai_response, chat_path_for_ai) return if rc != 0: - err = stderr_text or partial.strip() or "Bilinmeyen hata" - GLib.idle_add(self.handle_ai_error, err) + stream_failed = True + stream_timer_active = False + + with stream_lock: + stream_buffer.clear() + + if key in self.ai_stop_requested_chats: + GLib.idle_add(self.after_ai_response, chat_path_for_ai) + return + + GLib.idle_add( + self.handle_ai_error, + stderr_text or "AI process failed.", + chat_path_for_ai + ) + return + else: - GLib.idle_add(self.finalize_ai_response, partial) - GLib.idle_add(self.after_ai_response) + GLib.idle_add(self.finalize_ai_response, partial, chat_path_for_ai) + + if approved_web_search_query: + GLib.idle_add( + self.mark_latest_web_search_done, + chat_path_for_ai, + True, + "" + ) + + GLib.idle_add(self.after_ai_response, chat_path_for_ai) except Exception as e: - self.ai_proc = None - if self.ai_stop_requested: - GLib.idle_add(self.after_ai_response) - else: - GLib.idle_add(self.handle_ai_error, str(e)) + try: + stream_timer_active = False + stream_failed = True + + with stream_lock: + stream_buffer.clear() + except Exception: + pass + + if approved_web_search_query: + GLib.idle_add( + self.mark_latest_web_search_done, + chat_path_for_ai, + False, + str(e) + ) + + GLib.idle_add( + self.handle_ai_error, + str(e), + chat_path_for_ai + ) if __name__ == "__main__":