diff --git a/cli.py b/cli.py index a3ecbe5..fdcbcc7 100644 --- a/cli.py +++ b/cli.py @@ -6,6 +6,14 @@ import base64 import shutil import tempfile import subprocess +import codecs +import threading +import curses +import select +import time +import signal +import re +import webbrowser from pathlib import Path from datetime import datetime @@ -16,6 +24,10 @@ BASE_DIR = Path.home() / ".cache" / "capture-ai" CHAT_DIR = BASE_DIR / "chats" AI_SCRIPT = str(Path.home() / "capture-ai" / "ai.py") GENERATED_DIR = BASE_DIR / "generated_images" +GENERATED_FILES_DIR = BASE_DIR / "generated_files" +LANG_DIR = Path.home() / "capture-ai" / "language" + +GENERATED_FILES_DIR.mkdir(parents=True, exist_ok=True) CHAT_DIR.mkdir(parents=True, exist_ok=True) GENERATED_DIR.mkdir(parents=True, exist_ok=True) @@ -31,11 +43,29 @@ def fix_mojibake(s: str) -> str: except Exception: return s +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 class ChatCLI: def __init__(self): self.pending_images = [] self.pending_files = [] + self.pending_voices = [] + + self.selected_ref_indexes = set() + + self.current_ai_process = None + self.ai_cancel_requested = False cfg = self.load_config() last_chat_name = str(cfg.get("last_chat", "default.json") or "default.json").strip() @@ -44,10 +74,17 @@ class ChatCLI: self.current_chat = CHAT_DIR / last_chat_name if not self.current_chat.exists(): - self.current_chat.write_text("[]", encoding="utf-8") + self.current_chat.write_text( + json.dumps({ + "summary": "", + "messages": [], + "code_context": {}, + "memory_chunks": [] + }, ensure_ascii=False, indent=2), + encoding="utf-8" + ) self.ensure_base_config() - self.ensure_ui_i18n_config() self.active_model = self.get_chat_model(self.current_chat) def clear_screen(self): @@ -64,7 +101,7 @@ class ChatCLI: if not s: print(f"{self('o_Empty_Input')}") - self._read_input(f"{self('o_to_Menu')}") + self._read_input(f"\n{self('o_to_Menu')}") return True return s == "\x1b" or s.strip().lower() in {"esc", ":q"} @@ -88,200 +125,586 @@ class ChatCLI: except Exception as e: print(f"config save error: {e}") - def ensure_ui_i18n_config(self): - cfg = self.load_config() - changed = False - - if not isinstance(cfg.get("ui_language"), str) or not str(cfg.get("ui_language")).strip(): - cfg["ui_language"] = "en" - changed = True - - if not isinstance(cfg.get("ui_strings"), dict): - cfg["ui_strings"] = {} - changed = True - - if changed: - self.save_config(cfg) - - def get_ui_strings_map(self) -> dict: - cfg = self.load_config() - raw = cfg.get("ui_strings", {}) - return raw if isinstance(raw, dict) else {} - - def get_ui_lang_map(self, lang: str | None = None) -> dict: - if lang is None: - lang = self.get_ui_language() - - all_strings = self.get_ui_strings_map() - selected = all_strings.get(lang, {}) - if not isinstance(selected, dict): - selected = {} - return selected - - def __call__(self, key: str) -> str: - lang_map = self.get_ui_lang_map() - return str(lang_map.get(key, key)) - def ensure_base_config(self): cfg = self.load_config() changed = False - if "show_usage" not in cfg: - cfg["show_usage"] = True + defaults = { + "ui_language": "en", + "last_chat": "default.json", + "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, + "pinned_chats": [], + "chat_rag_switch": {"default.json": True}, + "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 + }, + "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": "" + } + }, + "is_mic_online": True, + "use_desktop_voice": False, + "stt_model_online": "openai/gpt-audio-mini", + "whisper_cpp_bin": "", + "whisper_cpp_model": "", + "use_stt_timeout": False, + "stt_timeout": "10", + "use_stt_silence": False, + "stt_silence_duration": "2", + } + + bool_keys = [ + "ask_for_web_search", + "show_usage", + "show_token_value", + "force_ui_language", + "is_mic_online", + "use_desktop_voice", + "use_stt_timeout", + "use_stt_silence", + ] + + for key, default in defaults.items(): + if key not in cfg: + cfg[key] = default + changed = True + continue + + if key in bool_keys: + fixed = ensure_bool_strict(cfg.get(key), default) + if fixed != cfg.get(key): + cfg[key] = fixed + changed = True + + # ai_models normalize: string -> dict + raw_models = cfg.get("ai_models", []) + if not isinstance(raw_models, list): + raw_models = [] + + fixed_models = [] + seen = set() + + for item in raw_models: + if isinstance(item, dict): + mid = str(item.get("id") or "").strip() + is_local = ensure_bool_strict(item.get("local"), False) + else: + mid = str(item or "").strip() + is_local = False + + if not mid or mid in seen: + continue + + seen.add(mid) + fixed_models.append({ + "id": mid, + "local": is_local + }) + + if not fixed_models: + fixed_models = list(defaults["ai_models"]) + + if fixed_models != cfg.get("ai_models"): + cfg["ai_models"] = fixed_models changed = True - if "ui_language" not in cfg: - cfg["ui_language"] = "tr" + # chat_models normalize + raw_chat_models = cfg.get("chat_models", {}) + if not isinstance(raw_chat_models, dict): + raw_chat_models = {} + + model_ids = {m["id"] for m in fixed_models} + fixed_chat_models = {} + + for chat_name, model in raw_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() + is_local = ensure_bool_strict(model.get("local"), False) + else: + mid = str(model or "").strip() + is_local = False + + if not mid or mid not in model_ids: + mid = fixed_models[0]["id"] + is_local = bool(fixed_models[0].get("local", False)) + else: + for m in fixed_models: + if m["id"] == mid: + is_local = bool(m.get("local", False)) + break + + fixed_chat_models[chat_key] = { + "id": mid, + "local": is_local + } + + if not fixed_chat_models: + fixed_chat_models = dict(defaults["chat_models"]) + + if fixed_chat_models != cfg.get("chat_models"): + cfg["chat_models"] = fixed_chat_models changed = True - if "response_style" not in cfg: - cfg["response_style"] = "" - changed = True + # rag_settings normalize - UI ile birebir + rag_defaults = defaults["rag_settings"] - if "force_ui_language" not in cfg: - cfg["force_ui_language"] = False + if not isinstance(cfg.get("rag_settings"), dict): + cfg["rag_settings"] = dict(rag_defaults) changed = True + else: + fixed_rag = dict(rag_defaults) + raw_rag = cfg.get("rag_settings", {}) - if "open_router_key" not in cfg: - cfg["open_router_key"] = "" - changed = True + int_keys = [ + "recent_message_count", + "retrieved_chunk_count", + "summary_update_every", + "memory_chunk_max_chars", + "summary_max_chars", + "code_context_max_chars", + ] - if "chat_models" not in cfg or not isinstance(cfg.get("chat_models"), dict): - cfg["chat_models"] = {} - changed = True + bool_rag_keys = [ + "use_summary", + "use_recent_messages", + "use_retrieval", + "use_code_context", + "include_recent_attachments", + ] - if "pinned_chats" not in cfg or not isinstance(cfg.get("pinned_chats"), list): - cfg["pinned_chats"] = [] - changed = True + for key in int_keys: + try: + val = int(raw_rag.get(key, rag_defaults[key])) + except Exception: + val = rag_defaults[key] - if "is_mic_online" not in cfg: - cfg["is_mic_online"] = True - changed = True + 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)) - if "use_desktop_voice" not in cfg: - cfg["use_desktop_voice"] = False - changed = True + fixed_rag[key] = val - if "stt_model_online" not in cfg: - cfg["stt_model_online"] = "openai/gpt-audio-mini" - changed = True + for key in bool_rag_keys: + fixed_rag[key] = ensure_bool_strict( + raw_rag.get(key, rag_defaults[key]), + rag_defaults[key] + ) - if "whisper_cpp_bin" not in cfg: - cfg["whisper_cpp_bin"] = "/home/$USER/whisper.cpp/build/bin/whisper-cli" - changed = True + if fixed_rag != cfg.get("rag_settings"): + cfg["rag_settings"] = fixed_rag + changed = True - if "whisper_cpp_model" not in cfg: - cfg["whisper_cpp_model"] = "/home/$USER/.local/share/whisper/ggml-tiny.bin" - 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": "" + } - models = self._get_models_list(cfg) - if not models: - cfg["ai_models"] = ["google/gemini-2.5-flash-image"] - changed = True + raw_providers = cfg.get("local_providers", {}) + if not isinstance(raw_providers, dict): + raw_providers = {} - if not str(cfg.get("last_chat", "")).strip(): - cfg["last_chat"] = "default.json" + fixed_providers = {} + + for provider_name, provider_cfg in raw_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"), False) + + for k in provider_defaults: + if k == "enabled": + continue + val = provider_cfg.get(k, provider_defaults[k]) + fixed[k] = str(val) if val is not None else "" + + if not fixed["base_url"].strip(): + fixed["base_url"] = provider_defaults["base_url"] + + fixed_providers[pname] = fixed + + if not fixed_providers: + fixed_providers = dict(defaults["local_providers"]) + + if fixed_providers != cfg.get("local_providers"): + cfg["local_providers"] = fixed_providers changed = True if changed: self.save_config(cfg) - 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().lower() - if ks: - out.append(ks) - - return out - 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" + lang = str(cfg.get("ui_language", "en") or "en").strip().lower() + return lang if lang else "en" def set_ui_language(self, lang: str): lang = str(lang or "").strip().lower() if not lang: return + cfg = self.load_config() cfg["ui_language"] = lang self.save_config(cfg) - def _get_models_list(self, cfg: dict): - models = cfg.get("ai_models", []) - if not isinstance(models, list): - models = [] - return [str(m).strip() for m in models if str(m).strip()] + def get_ui_lang_map(self, lang: str | None = None) -> dict: + lang = str(lang or self.get_ui_language() or "en").strip().lower() - def _ensure_min_one_model(self, cfg: dict): + lang_path = LANG_DIR / f"{lang}.json" + fallback_path = LANG_DIR / "en.json" + + data = {} + + try: + if lang_path.exists(): + raw = json.loads(lang_path.read_text(encoding="utf-8")) or {} + if isinstance(raw, dict): + data = raw + except Exception as e: + print(f"language file read error ({lang_path.name}): {e}") + + if not data: + try: + if fallback_path.exists(): + raw = json.loads(fallback_path.read_text(encoding="utf-8")) or {} + if isinstance(raw, dict): + data = raw + except Exception as e: + print(f"fallback language file read error ({fallback_path.name}): {e}") + + return data if isinstance(data, dict) else {} + + def __call__(self, key: str, **kwargs) -> str: + lang_map = self.get_ui_lang_map() + text = str(lang_map.get(key, key)) + + try: + return text.format(**kwargs) + except Exception: + return text + + def get_available_ui_languages(self) -> list[str]: + out = [] + + try: + if LANG_DIR.exists(): + for p in LANG_DIR.glob("*.json"): + name = p.stem.strip().lower() + if name: + out.append(name) + except Exception as e: + print(f"language directory read error: {e}") + + return sorted(set(out)) + + def _normalize_model_entry(self, item): + if isinstance(item, dict): + mid = str(item.get("id") or "").strip() + if not mid: + return None + return { + "id": mid, + "local": ensure_bool_strict(item.get("local"), False) + } + + mid = str(item or "").strip() + if not mid: + return None + + return { + "id": mid, + "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 + + if norm["id"] in seen: + continue + + seen.add(norm["id"]) + out.append(norm) + + return out + + def _ensure_min_one_model(self, cfg: dict) -> list[dict]: 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_chat_model(self, chat_path: Path) -> str: + def _find_model_entry(self, cfg: dict, model_id: str): + model_id = str(model_id or "").strip() + + for item in self._get_models_list(cfg): + if item["id"] == model_id: + return item + + return None + + def get_chat_model_entry(self, chat_path: Path) -> dict: cfg = self.load_config() + self.ensure_base_config() + cfg = 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 = {} - key = chat_path.name - m = str(chat_models.get(key) or "").strip() + selected = chat_models.get(chat_path.name) - if (not m) or (m not in models): - m = str(models[0]) - chat_models[key] = m + if isinstance(selected, dict): + model_id = str(selected.get("id") or "").strip() + else: + model_id = str(selected or "").strip() + + if not model_id or model_id not in model_ids: + chosen = models[0] + else: + chosen = self._find_model_entry(cfg, model_id) or models[0] + + fixed = { + "id": chosen["id"], + "local": bool(chosen.get("local", False)) + } + + if selected != fixed: + chat_models[chat_path.name] = fixed cfg["chat_models"] = chat_models self.save_config(cfg) - return m + return fixed - def set_chat_model(self, chat_path: Path, model_id: str): - model_id = str(model_id).strip() + def get_chat_model(self, chat_path: Path) -> str: + return self.get_chat_model_entry(chat_path)["id"] + + def set_chat_model(self, chat_path: Path, model_id: str, is_local: bool | None = None): + model_id = str(model_id or "").strip() if not model_id: return cfg = self.load_config() + self.ensure_base_config() + cfg = self.load_config() + models = self._ensure_min_one_model(cfg) - 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: + found = { + "id": model_id, + "local": bool(item.get("local", False)) if is_local is None else bool(is_local) + } + else: + rest.append(item) + + if found is None: + found = { + "id": model_id, + "local": bool(is_local) if is_local is not None else False + } + + cfg["ai_models"] = [found] + rest chat_models = cfg.get("chat_models", {}) if not isinstance(chat_models, dict): chat_models = {} - chat_models[chat_path.name] = model_id + + chat_models[chat_path.name] = { + "id": found["id"], + "local": bool(found.get("local", False)) + } + cfg["chat_models"] = chat_models + self.save_config(cfg) + + self.active_model = found["id"] + + def get_context_mode(self, chat_path=None) -> str: + chat_path = Path(chat_path or self.current_chat) + + cfg = self.load_config() + self.ensure_base_config() + cfg = self.load_config() + + chat_rag_switch = cfg.get("chat_rag_switch", {}) + if not isinstance(chat_rag_switch, dict): + chat_rag_switch = {} + + # True = direct, False = rag + 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 = self.load_config() + self.ensure_base_config() + cfg = self.load_config() + + chat_rag_switch = cfg.get("chat_rag_switch", {}) + if not isinstance(chat_rag_switch, dict): + chat_rag_switch = {} + + chat_rag_switch[chat_path.name] = (mode == "direct") + cfg["chat_rag_switch"] = chat_rag_switch self.save_config(cfg) - self.active_model = model_id # ---------------- CHAT FILES ---------------- - def load_chat_messages(self): + def load_chat_data_cli(self, path=None): + path = Path(path or self.current_chat) + try: - with open(self.current_chat, "r", encoding="utf-8") as f: - return json.load(f) or [] + raw = json.loads(path.read_text(encoding="utf-8")) or [] except Exception: - return [] + 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", []) + + if not isinstance(raw["messages"], list): + raw["messages"] = [] + + return raw + + def save_chat_data_cli(self, data, path=None): + path = Path(path or self.current_chat) + + if not isinstance(data, dict): + data = { + "summary": "", + "messages": [], + "code_context": {}, + "memory_chunks": [] + } + + data.setdefault("summary", "") + data.setdefault("messages", []) + data.setdefault("code_context", {}) + data.setdefault("memory_chunks", []) + + path.write_text( + json.dumps(data, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + def load_chat_messages(self): + return self.load_chat_data_cli().get("messages", []) def save_chat_messages(self, messages): - with open(self.current_chat, "w", encoding="utf-8") as f: - json.dump(messages, f, ensure_ascii=False, indent=2) + data = self.load_chat_data_cli() + data["messages"] = messages + self.save_chat_data_cli(data) def list_chat_files(self): cfg = self.load_config() @@ -311,7 +734,15 @@ class ChatCLI: break i += 1 - path.write_text("[]", encoding="utf-8") + path.write_text( + json.dumps({ + "summary": "", + "messages": [], + "code_context": {}, + "memory_chunks": [] + }, ensure_ascii=False, indent=2), + encoding="utf-8" + ) self.current_chat = path cfg = self.load_config() @@ -321,23 +752,27 @@ class ChatCLI: chat_models = cfg.get("chat_models", {}) if not isinstance(chat_models, dict): chat_models = {} - chat_models[path.name] = str(models[0]) + + chat_models[path.name] = { + "id": models[0]["id"], + "local": bool(models[0].get("local", False)) + } cfg["chat_models"] = chat_models self.save_config(cfg) self.active_model = self.get_chat_model(self.current_chat) print(f"\n✔ {self('o_New_Chat')}: {path.stem}") - self._read_input(f"{self('o_to_Menu')}") + self._read_input(f"\n{self('o_to_Menu')}") def delete_chat(self, file_path: Path): if not file_path.exists(): - print("Chat bulunamadı.") + print(self("o_Chat_Not_Found")) return try: file_path.unlink() except Exception as e: - print(f"Delete error: {e}") + print(self("o_Delete_Error", error=e)) return cfg = self.load_config() @@ -375,7 +810,7 @@ class ChatCLI: def rename_chat(self, old_path: Path, new_name_raw: str): new_name = (new_name_raw or "").strip() if not new_name: - print("Geçersiz isim.") + print(self("o_Invalid_Name")) return for ch in ["/", "\\", "\n", "\r", "\t"]: @@ -388,13 +823,13 @@ class ChatCLI: return if new_path.exists(): - print("Bu isimde chat zaten var.") + print(self("o_Chat_Name_Exists")) return try: old_path.rename(new_path) except Exception as e: - print(f"Yeniden adlandırma hatası: {e}") + print(self("o_Rename_Error", error=e)) return cfg = self.load_config() @@ -441,7 +876,7 @@ class ChatCLI: def select_chat(self): chats = self.list_chat_files() if not chats: - print("Hiç chat yok.") + print(self("o_No_Chats")) return cfg = self.load_config() @@ -470,7 +905,790 @@ class ChatCLI: self.active_model = self.get_chat_model(self.current_chat) print(f"\n{self('o_Active_Chat')}: {self.current_chat.stem}") - self._read_input(f"{self('o_to_Menu')}") + self._read_input(f"\n{self('o_to_Menu')}") + + # ---------------- LOCAL PROVIDERS --------------- + + def menu_local_providers(self): + while True: + self.clear_screen() + cfg = self.load_config() + providers = cfg.get("local_providers", {}) + if not isinstance(providers, dict): + providers = {} + + names = list(providers.keys()) + + print(f"\n{self('o_AI_Providers')}") + + for i, name in enumerate(names, 1): + pcfg = providers.get(name, {}) + enabled = self("o_On") if pcfg.get("enabled") else self("o_Off") + base_url = pcfg.get("base_url", "") + print(f"{i}) {name} [{enabled}] {base_url}") + + print(f"{len(names) + 1}) {self('o_Add_Provider')}") + print(f"{len(names) + 2}) {self('o_Go_Back')}") + + choice = self._read_input(f"\n{self('o_Selection')}: ").strip() + + if not choice.isdigit(): + continue + + num = int(choice) + + if num == len(names) + 2: + return + + if num == len(names) + 1: + name = self._read_input(f"{self('o_Provider_Name')}: ").strip() + if not name: + continue + + providers[name] = { + "enabled": True, + "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": "" + } + + cfg["local_providers"] = providers + self.save_config(cfg) + continue + + idx = num - 1 + if not (0 <= idx < len(names)): + continue + + self.menu_edit_local_provider(names[idx]) + + def menu_edit_local_provider(self, provider_name: str): + keys = [ + "enabled", + "base_url", + "run_startup", + "stop_command", + "system_error", + "temperature", + "top_p", + "top_k", + "repeat_penalty", + "num_ctx", + "num_predict", + "keep_alive", + "system_prompt", + ] + + while True: + self.clear_screen() + + cfg = self.load_config() + providers = cfg.get("local_providers", {}) + pcfg = providers.get(provider_name, {}) + + print(f"\n={self('o_AI_Provider')}: {provider_name}=") + + for i, key in enumerate(keys, 1): + print(f"{i}) {key}: {pcfg.get(key, '')}") + + print(f"{len(keys) + 1}) {self('o_Delete_Provider')}") + print(f"{len(keys) + 2}) {self('o_Go_Back')}") + + choice = self._read_input(f"\n{self('o_Selection')}: ").strip() + + if not choice.isdigit(): + continue + + num = int(choice) + + if num == len(keys) + 2: + return + + if num == len(keys) + 1: + confirm = self._read_input(f"{self('o_Delete')}? ({self('o_Yes')}/{self('o_No')}): ").strip().lower() + if confirm in ("e", "evet", "y", "yes"): + providers.pop(provider_name, None) + cfg["local_providers"] = providers + self.save_config(cfg) + return + continue + + idx = num - 1 + if not (0 <= idx < len(keys)): + continue + + key = keys[idx] + + if key == "enabled": + pcfg["enabled"] = not bool(pcfg.get("enabled", False)) + else: + new_val = self._read_input(f"{self('o_New_Value')} ") + pcfg[key] = new_val + + providers[provider_name] = pcfg + cfg["local_providers"] = providers + self.save_config(cfg) + + # ---------------- PROMPT CHOOSER ---------------- + + def get_prompt_chooser_blocks(self) -> list[str]: + cfg = 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 = self.load_config() + cfg["prompt_chooser_blocks"] = list(blocks) + self.save_config(cfg) + + def menu_prompt_chooser(self): + 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"), + ] + + while True: + self.clear_screen() + selected = set(self.get_prompt_chooser_blocks()) + + print(f"\n={self('o_Prompt_Chooser')}=") + for i, (key, label_key) in enumerate(items, 1): + mark = "✅" if key in selected else "⬜" + print(f"{i}) {mark} {self(label_key)}") + + print(f"{len(items) + 1}) {self('o_Go_Back')}") + + choice = self._read_input(f"\n{self('o_Selection')}: ").strip() + + if not choice.isdigit(): + continue + + num = int(choice) + + if num == len(items) + 1: + return + + idx = num - 1 + if not (0 <= idx < len(items)): + continue + + key = items[idx][0] + + if key in selected: + selected.remove(key) + else: + selected.add(key) + + self.save_prompt_chooser_blocks([k for k, _ in items if k in selected]) + + # -------------------- COPYABLE ------------------ + + def extract_named_block(self, text: str, name: str) -> str | None: + import re + + 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 extract_apply_block(self, text: str) -> str | None: + return self.extract_named_block(text, "apply") + + + def parse_apply_ops(self, apply_block: str): + try: + ops = json.loads(apply_block) + if isinstance(ops, dict): + ops = [ops] + if not isinstance(ops, list): + return [], "apply JSON list değil." + return ops, "" + except Exception as e: + return [], str(e) + + + def apply_edit_op_cli(self, file_path: str, find: str, mode: str, text: str): + try: + p = Path(file_path) + + if not p.exists(): + return False, self("o_Apply_File_Not_Found", path=p) + + original = p.read_text(encoding="utf-8", errors="ignore") + + idx = original.find(find) + if idx < 0: + return False, self("o_Apply_Find_Failed", name=p.name) + + if mode == "before": + new_content = original[:idx] + text + original[idx:] + elif mode == "after": + new_content = original[:idx + len(find)] + text + original[idx + len(find):] + elif mode == "replace": + new_content = original.replace(find, text, 1) + else: + return False, self("o_Apply_Unknown_Mode", mode=mode) + + if new_content == original: + return False, self("o_Apply_No_Changes") + + p.write_text(new_content, encoding="utf-8") + return True, "" + + except Exception as e: + return False, self("o_Apply_Exception", error=e) + + + def apply_ops_yes_cli(self, ops): + if not isinstance(ops, list) or not ops: + return False, self("o_Apply_No_Ops") + + for op in ops: + if not isinstance(op, dict): + continue + + path = str(op.get("path") or "").strip() + find = str(op.get("find") or "") + mode = str(op.get("mode") or "").strip().lower() + text = str(op.get("text") or "") + + ok, err = self.apply_edit_op_cli(path, find, mode, text) + if not ok: + return False, err + + return True, "" + + + def set_apply_status_cli(self, msg_index: int, status: str, err: str = ""): + data = self.load_chat_data_cli() + messages = data.get("messages", []) + + if not (0 <= msg_index < len(messages)): + return + + files = [] + old_status = messages[msg_index].get("apply_status") + + if isinstance(old_status, dict) and isinstance(old_status.get("files"), list): + files = old_status.get("files") + + messages[msg_index]["apply_status"] = { + "status": status, + "err": str(err or "").strip(), + "files": files + } + + data["messages"] = messages + self.save_chat_data_cli(data) + + + def find_pending_apply_request(self): + messages = self.load_chat_messages() + + for i in range(len(messages) - 1, -1, -1): + msg = messages[i] + if not isinstance(msg, dict): + continue + + if msg.get("role") == "user": + break + + if isinstance(msg.get("apply_status"), dict): + continue + + content = str(msg.get("content") or "") + apply_block = self.extract_apply_block(content) + + if not apply_block: + continue + + ops, err = self.parse_apply_ops(apply_block) + if err or not ops: + continue + + valid_ops = [] + valid = True + + for op in ops: + p_raw = str((op or {}).get("path") or "").strip() + p = Path(p_raw) + + if (not p.is_absolute()) or (not p.exists()): + valid = False + break + + valid_ops.append(op) + + if valid and valid_ops: + return i, msg, valid_ops + + return None, None, None + + + def handle_pending_apply_requests(self) -> bool: + msg_index, msg, ops = self.find_pending_apply_request() + + if not ops: + return False + + print(f"\n{self('o_Apply_Changes_Request')}") + + paths = [] + for op in ops: + p = Path(str(op.get("path") or "")) + short = f"{p.parent.name}/{p.name}" if p.parent.name else p.name + if short not in paths: + paths.append(short) + + try: + data = self.load_chat_data_cli() + messages = data.get("messages", []) + + if 0 <= msg_index < len(messages): + old = messages[msg_index].get("apply_status") + if not isinstance(old, dict): + old = {} + + old["files"] = paths + messages[msg_index]["apply_status"] = old + data["messages"] = messages + self.save_chat_data_cli(data) + except Exception: + pass + + for p in paths: + print(f"- {p}") + + print() + print(f"1) {self('o_Apply')}") + print(f"2) {self('o_Cancel')}") + + choice = self._read_input(f"\n{self('o_Selection')}: ").strip() + + if choice == "1": + ok, err = self.apply_ops_yes_cli(ops) + + if ok: + self.set_apply_status_cli(msg_index, "ok") + print(f"\n✅ {self('o_File_Changed')}") + else: + self.set_apply_status_cli(msg_index, "fail", err) + print(f"\n❌ {self('o_Apply_Failed_Input', error=err)}") + + self._read_input(f"\n{self('o_to_Menu')}") + return True + + elif choice == "2": + self.set_apply_status_cli(msg_index, "cancel") + print(f"\n⏭️ {self('o_Cancelled')}") + self._read_input(f"\n{self('o_to_Menu')}") + return True + + return True + + # ------------------ WEB SEARCH ------------------ + + def find_pending_web_search_request(self): + messages = self.load_chat_messages() + + for i in range(len(messages) - 1, -1, -1): + msg = messages[i] + if not isinstance(msg, dict): + continue + + req = msg.get("web_search_request") + if isinstance(req, dict) and req.get("status") == "pending": + return i, msg, req + + return None, None, None + + + def set_web_search_status_cli(self, msg_index: int, status: str, err: str = ""): + data = self.load_chat_data_cli() + messages = data.get("messages", []) + + 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"] = str(err or "").strip() + messages[msg_index]["web_search_request"] = req + + data["messages"] = messages + self.save_chat_data_cli(data) + + + def call_ai_with_web_search_approval(self, query: str): + cmd = [ + sys.executable, + "-u", + AI_SCRIPT, + str(self.current_chat), + "--approved-web-search-query", + query + ] + + try: + env = os.environ.copy() + env["PYTHONUTF8"] = "1" + env["PYTHONIOENCODING"] = "utf-8" + env["PYTHONUNBUFFERED"] = "1" + + proc = subprocess.run( + cmd, + capture_output=True, + text=False, + env=env + ) + + stdout_text = proc.stdout.decode("utf-8", errors="replace") + stderr_text = proc.stderr.decode("utf-8", errors="replace") + + stdout_text = fix_mojibake(stdout_text) + stderr_text = fix_mojibake(stderr_text) + + if proc.returncode != 0: + err = (stderr_text or stdout_text or "Bilinmeyen hata").strip() + self.handle_ai_error(err) + return None + + return self.finalize_ai_response(stdout_text) + + except Exception as e: + self.handle_ai_error(str(e)) + return None + + + def handle_pending_web_search_requests(self) -> bool: + msg_index, msg, req = self.find_pending_web_search_request() + + if not isinstance(req, dict): + return False + + query = str(req.get("query") or "").strip() + + print() + print(self("o_Web_Search_Request_With_Query", query=query)) + print() + print(f"1) {self('o_Search')}") + print(f"2) {self('o_Cancel')}") + + choice = self._read_input(f"\n{self('o_Selection')}: ").strip() + + if choice == "1": + self.set_web_search_status_cli(msg_index, "searching") + bot_msg = self.call_ai_with_web_search_approval(query) + + if bot_msg: + self.set_web_search_status_cli(msg_index, "searched") + self.print_last_bot_message(bot_msg) + + self._read_input(f"\n{self('o_to_Menu')}") + return True + + elif choice == "2": + self.set_web_search_status_cli(msg_index, "cancelled") + print(f"\n⏭️ {self('o_Cancelled')}") + self._read_input(f"\n{self('o_to_Menu')}") + return True + + return True + + # ----------------- STT SETTINGS ----------------- + + def menu_stt_settings(self): + while True: + self.clear_screen() + cfg = self.load_config() + + is_online = bool(cfg.get("is_mic_online", True)) + use_desktop = bool(cfg.get("use_desktop_voice", False)) + + stt_model = str( + cfg.get("stt_model_online", "openai/gpt-audio-mini") or "" + ) + + whisper_bin = str(cfg.get("whisper_cpp_bin", "") or "") + whisper_model = str(cfg.get("whisper_cpp_model", "") or "") + + use_timeout = bool(cfg.get("use_stt_timeout", False)) + use_silence = bool(cfg.get("use_stt_silence", False)) + timeout = str(cfg.get("stt_timeout", "10") or "10") + silence = str(cfg.get("stt_silence_duration", "2") or "2") + + print(f"\n{self('o_STT_Settings')}") + + print( + f"1) {self('o_Online_STT_Text')}: " + f"{'✅' if is_online else '❌'}" + ) + + print( + f"2) {self('o_Use_Desktop_Audio_Text')}: " + f"{'✅' if use_desktop else '❌'}" + ) + + print( + f"3) {self('o_Online_STT_Model')}: " + f"{stt_model}" + ) + + print( + f"4) whisper.cpp {self('o_Binary_Path')}: " + f"{whisper_bin}" + ) + + print( + f"5) whisper.cpp {self('o_Model_Path')}: " + f"{whisper_model}" + ) + + print( + f"6) {self('o_Use_Timeout')}: " + f"{'✅' if use_timeout else '❌'}" + ) + + print(f"7) {self('o_Timeout')}: {timeout}") + + print(f"8) {self('o_Use_Silence_Auto_Stop')}: {'✅' if use_silence else '❌'}") + print(f"9) {self('o_Silence_Duration')}: {silence}") + print(f"10) {self('o_Go_Back')}") + + choice = self._read_input( + f"\n{self('o_Selection')}: " + ).strip() + + if choice == "1": + cfg["is_mic_online"] = not is_online + self.save_config(cfg) + + elif choice == "2": + cfg["use_desktop_voice"] = not use_desktop + self.save_config(cfg) + + elif choice == "3": + val = self._read_input( + f"{self('o_New_Value')} " + ).strip() + + if not self._is_escape_input(val): + cfg["stt_model_online"] = val + self.save_config(cfg) + + elif choice == "4": + val = self._read_input( + f"{self('o_New_Value')} " + ).strip() + + if not self._is_escape_input(val): + cfg["whisper_cpp_bin"] = val + self.save_config(cfg) + + elif choice == "5": + val = self._read_input( + f"{self('o_New_Value')} " + ).strip() + + if not self._is_escape_input(val): + cfg["whisper_cpp_model"] = val + self.save_config(cfg) + + elif choice == "6": + cfg["use_stt_timeout"] = not use_timeout + self.save_config(cfg) + + elif choice == "7": + val = self._read_input( + f"{self('o_New_Value')} " + ).strip() + + if not self._is_escape_input(val): + cfg["stt_timeout"] = val + self.save_config(cfg) + + elif choice == "8": + cfg["use_stt_silence"] = not use_silence + self.save_config(cfg) + + elif choice == "9": + val = self._read_input( + f"{self('o_New_Value')} " + ).strip() + + if not self._is_escape_input(val): + cfg["stt_silence_duration"] = val + self.save_config(cfg) + + elif choice == "10": + return + + # ----------------- RAG SETTINGS------------------ + + def menu_rag_settings(self): + int_settings = [ + ("recent_message_count", "o_RAG_Recent_Count"), + ("retrieved_chunk_count", "o_RAG_Retrieved_Count"), + ("summary_update_every", "o_RAG_Summary_Every"), + ("memory_chunk_max_chars", "o_RAG_Memory_Chunk_Max"), + ("summary_max_chars", "o_RAG_Summary_Max"), + ("code_context_max_chars", "o_RAG_Code_Context_Max"), + ] + + bool_settings = [ + ("use_summary", "o_RAG_Use_Summary"), + ("use_recent_messages", "o_RAG_Use_Recent"), + ("use_retrieval", "o_RAG_Use_Retrieval"), + ("use_code_context", "o_RAG_Use_Code_Context"), + ("include_recent_attachments", "o_RAG_Recent_Attachments"), + ] + + while True: + self.clear_screen() + + cfg = self.load_config() + self.ensure_base_config() + cfg = self.load_config() + + rag = cfg.get("rag_settings", {}) + if not isinstance(rag, dict): + rag = {} + + print(f"\n={self('o_RAG_Settings')}=") + + n = 1 + + for key, label_key in int_settings: + print(f"{n}) {self(label_key)}: {rag.get(key, '')}") + n += 1 + + for key, label_key in bool_settings: + val = bool(rag.get(key, False)) + print(f"{n}) {self(label_key)}: {'✅' if val else '❌'}") + n += 1 + + back_num = n + print(f"{back_num}) {self('o_Go_Back')}") + + choice = self._read_input(f"\n{self('o_Selection')}: ").strip() + + if not choice.isdigit(): + continue + + num = int(choice) + + if num == back_num: + return + + if 1 <= num <= len(int_settings): + key, label_key = int_settings[num - 1] + + new_value = self._read_input( + f"{self(label_key)} ({self('o_New_Value')}) " + ).strip() + + if self._is_escape_input(new_value): + continue + + try: + val = int(new_value) + + 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)) + + rag[key] = val + cfg["rag_settings"] = rag + self.save_config(cfg) + + except Exception: + print(f"\n{self('o_Invalid_Value')}") + self._read_input(f"\n{self('o_to_Menu')}") + + continue + + bool_start = len(int_settings) + 1 + bool_end = len(int_settings) + len(bool_settings) + + if bool_start <= num <= bool_end: + idx = num - bool_start + key, _label_key = bool_settings[idx] + + rag[key] = not bool(rag.get(key, False)) + cfg["rag_settings"] = rag + self.save_config(cfg) + continue + + + + # ----------------- CONTEXT MODE ----------------- + + def menu_context_mode(self): + while True: + self.clear_screen() + mode = self.get_context_mode(self.current_chat) + + mode_label = self("o_Context_Direct") if mode == "direct" else self("o_Context_RAG") + + print(f"\n={self('o_Context_Mode_Text')}=") + print(f"{self('o_Current')}: {mode_label}") + print(f"1) {self('o_Context_RAG')}") + print(f"2) {self('o_Context_Direct')}") + print(f"3) {self('o_Go_Back')}") + + choice = self._read_input(f"\n{self('o_Selection')}: ").strip() + + if choice == "1": + self.set_context_mode("rag", self.current_chat) + print(f"\n✔ {self('o_Context_RAG')}") + self._read_input(f"\n{self('o_to_Menu')}") + + elif choice == "2": + self.set_context_mode("direct", self.current_chat) + print(f"\n✔ {self('o_Context_Direct')}") + self._read_input(f"\n{self('o_to_Menu')}") + + elif choice == "3": + return + # ---------------- IMAGES / FILES ---------------- @@ -478,6 +1696,23 @@ class ChatCLI: def list_pending_attachments(self): items = [] + for v in self.pending_voices: + if isinstance(v, dict): + p = v.get("path", "") + text = v.get("text", "") + else: + p = str(v) + text = "" + + pp = Path(str(p)) + + items.append({ + "type": "voice", + "name": pp.name, + "path": str(pp.resolve()) if pp.exists() else str(pp), + "text": str(text or "").strip() + }) + for p in self.pending_images: pp = Path(str(p)) items.append({ @@ -488,10 +1723,12 @@ class ChatCLI: for f in self.pending_files: fp = Path(str(f.get("path", ""))) + editable = bool(f.get("edit", False)) items.append({ "type": "file", "name": f.get("name") or fp.name, - "path": str(fp.resolve()) if fp.exists() else str(fp) + "path": str(fp.resolve()) if fp.exists() else str(fp), + "edit": editable }) return items @@ -505,7 +1742,13 @@ class ChatCLI: item = items[idx] - if item["type"] == "image": + if item["type"] == "voice": + self.pending_voices = [ + x for x in self.pending_voices + if str(Path(str(x.get("path") if isinstance(x, dict) else x)).resolve()) != item["path"] + ] + + elif item["type"] == "image": self.pending_images = [ x for x in self.pending_images if str(Path(str(x)).resolve()) != item["path"] @@ -517,9 +1760,34 @@ class ChatCLI: ] print(f"\n✔ {self('o_Delete')}: {item['name']}") - self._read_input(f"{self('o_to_Menu')}") + self._read_input(f"\n{self('o_to_Menu')}") return True + def toggle_pending_file_edit_by_number(self, number: int) -> bool: + items = self.list_pending_attachments() + idx = number - 201 + + if not (0 <= idx < len(items)): + return False + + item = items[idx] + + if item["type"] != "file": + return False + + for f in self.pending_files: + fp = Path(str(f.get("path", ""))) + fpath = str(fp.resolve()) if fp.exists() else str(fp) + + if fpath == item["path"]: + f["edit"] = not bool(f.get("edit", False)) + mark = "ON" if f["edit"] else "OFF" + print(f"\n✔ Edit {mark}: {item['name']}") + self._read_input(f"\n{self('o_to_Menu')}") + return True + + return False + def save_base64_image(self, b64_data: str, ext: str = "png") -> str | None: try: raw = base64.b64decode(b64_data) @@ -571,8 +1839,8 @@ class ChatCLI: p = Path(path) if not p.exists(): - print("Dosya bulunamadı.") - self._read_input("\nMenüye dönmek için Enter...") + print(self("o_File_Not_Found")) + self._read_input(f"\n{self('o_to_Menu')}") return False import mimetypes @@ -582,22 +1850,32 @@ class ChatCLI: if mime.startswith("image/"): if str(p) not in self.pending_images: self.pending_images.append(str(p)) - print(f"Eklendi (image): {p.name}") + print(self("o_Image_Added", name=p.name)) else: if not any(x.get("path") == str(p) for x in self.pending_files): + edit_default = False + ext = p.suffix.lower() + + if ext in (".docx", ".pdf", ".xlsx", ".txt", ".md"): + ans = self._read_input( + f"{self('o_Edit_Mode')} ({self('o_Yes')}/{self('o_No')}): " + ).strip().lower() + + edit_default = ans in ("e", "evet", "y", "yes") self.pending_files.append({ "path": str(p), "name": p.name, - "edit": False + "edit": edit_default }) - print(f"Eklendi (file): {p.name}") + + print(self("o_File_Added", name=p.name)) return True def _capture_photo_from_script(self): script = Path.home() / ".config" / "scripts" / "screenprint.sh" if not script.exists(): - print(f"Script bulunamadı: {script}") + print(self("o_Script_Not_Found", path=script)) return None watch_dirs = [Path.home() / "Resimler", Path.home() / "Pictures", Path("/tmp")] @@ -632,7 +1910,7 @@ class ChatCLI: ) if p.returncode != 0: - print("Fotoğraf çekme iptal edildi veya başarısız oldu.") + print(self("o_Photo_Capture_Cancelled_Or_Failed")) return None out = (p.stdout or "").strip() @@ -671,7 +1949,7 @@ class ChatCLI: return str(newest.resolve()) except Exception as e: - print(f"Fotoğraf çekme hatası: {e}") + print(self("o_Photo_Capture_Error", error=e)) return None def take_photo_and_queue(self): @@ -686,35 +1964,707 @@ class ChatCLI: if path not in self.pending_images: self.pending_images.append(path) - print(f"\nFotoğraf eklendi: {Path(path).name}") + print(f"\n{self('o_Photo_Added', name=Path(path).name)}") return True + # --------------- VOICE -------------- + + def _get_use_desktop_voice(self) -> bool: + cfg = self.load_config() + return bool(cfg.get("use_desktop_voice", False)) + + + def _is_monitor_like_name(self, name: str) -> bool: + s = str(name or "").strip().lower() + + bad_words = [ + "monitor", + ".monitor", + "loopback", + "stereo mix", + "what u hear", + "mix monitor", + "output", + ] + + return any(w in s for w in bad_words) + + + def _detect_pw_desktop_target(self) -> str | None: + pactl = shutil.which("pactl") + if not pactl: + return None + + try: + p = subprocess.run( + [pactl, "list", "short", "sources"], + capture_output=True, + text=True + ) + + if p.returncode != 0: + return None + + for line in (p.stdout or "").splitlines(): + parts = line.split("\t") + + if len(parts) < 2: + continue + + source_name = parts[1].strip() + + if source_name and self._is_monitor_like_name(source_name): + return source_name + + except Exception: + pass + + return None + + + def _detect_pw_mic_target(self) -> str | None: + pactl = shutil.which("pactl") + + if pactl: + try: + p = subprocess.run( + [pactl, "list", "short", "sources"], + capture_output=True, + text=True + ) + + if p.returncode == 0: + lines = (p.stdout or "").splitlines() + + for line in lines: + parts = line.split("\t") + + if len(parts) < 2: + continue + + source_name = parts[1].strip() + + if source_name and not self._is_monitor_like_name(source_name): + return source_name + + for line in lines: + parts = line.split("\t") + + if len(parts) < 2: + continue + + source_name = parts[1].strip() + + if source_name: + return source_name + + except Exception: + pass + + wpctl = shutil.which("wpctl") + + if wpctl: + try: + p = subprocess.run( + [wpctl, "status"], + capture_output=True, + text=True + ) + + if p.returncode == 0: + for line in (p.stdout or "").splitlines(): + low = line.lower() + + if "*" in line and "source" in low and "monitor" not in low: + parts = line.strip().split() + + for token in parts: + token = token.strip(".") + + if token.isdigit(): + return token + + except Exception: + pass + + return None + + + def _detect_arecord_mic_device(self) -> str | None: + ar = shutil.which("arecord") + + if not ar: + return None + + try: + p = subprocess.run( + [ar, "-L"], + capture_output=True, + text=True + ) + + if p.returncode != 0: + return None + + lines = [ + x.strip() + for x in (p.stdout or "").splitlines() + if x.strip() + ] + + for name in lines: + low = name.lower() + + if low.startswith("plughw:") and "null" not in low: + return name + + for name in lines: + low = name.lower() + + if low.startswith("hw:") and "null" not in low: + return name + + for name in lines: + low = name.lower() + + if low == "default": + return name + + except Exception: + pass + + return None + + def _wait_recording_stop(self, proc, timeout: float, use_timeout: bool, use_silence: bool): + start = time.time() + + while True: + if proc.poll() is not None: + return "process_stopped" + + elapsed = time.time() - start + + # ENTER ile durdurma + try: + r, _, _ = select.select([sys.stdin], [], [], 0.15) + if r: + sys.stdin.readline() + return "manual" + except Exception: + pass + + # Silence detection + if use_silence and proc.stderr: + try: + r, _, _ = select.select([proc.stderr], [], [], 0) + if r: + line = proc.stderr.readline() + if line: + low = line.decode("utf-8", errors="ignore").lower() + + if "silence_start" in low and elapsed >= 1.5: + return "silence" + except Exception: + pass + + # Timeout + if use_timeout: + remaining = int(timeout - elapsed) + + if remaining <= 0: + print() + return "timeout" + + print(f"\r⏳ {remaining}", end="", flush=True) + + + def record_voice_to_file(self): + cfg = self.load_config() + + GENERATED_FILES_DIR.mkdir(parents=True, exist_ok=True) + + stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f") + out = GENERATED_FILES_DIR / f"voice-{stamp}.wav" + + pw = shutil.which("pw-record") + ar = shutil.which("arecord") + + ffmpeg = shutil.which("ffmpeg") + use_silence = bool(cfg.get("use_stt_silence", False)) + + try: + silence_duration = float(str(cfg.get("stt_silence_duration", "2")).replace(",", ".")) + except Exception: + silence_duration = 2.0 + + use_desktop_voice = self._get_use_desktop_voice() + + if use_silence and ffmpeg: + cmd = [ + ffmpeg, + "-nostdin", + "-y", + "-f", "pulse", + "-i", "default", + "-ac", "1", + "-ar", "16000", + "-af", f"silencedetect=n=-45dB:d={silence_duration}", + str(out) + ] + + elif pw: + if use_desktop_voice: + target = self._detect_pw_desktop_target() + + if not target: + print(self("o_Desktop_Audio_Monitor_Not_Found")) + return None + else: + target = self._detect_pw_mic_target() + + if not target: + print(self("o_Microphone_Source_Not_Found")) + return None + + cmd = [ + pw, + "--rate", "16000", + "--channels", "1", + "--target", str(target), + str(out) + ] + + elif ar: + if use_desktop_voice: + print(self("o_Desktop_Audio_Requires_PWRecord")) + return None + + device = self._detect_arecord_mic_device() + + if not device: + print(self("o_Microphone_Device_Not_Found")) + return None + + cmd = [ + ar, + "-D", str(device), + "-f", "S16_LE", + "-r", "16000", + "-c", "1", + str(out) + ] + + else: + print(self("o_Audio_Recorder_Not_Found")) + return None + + use_timeout = bool(cfg.get("use_stt_timeout", False)) + + try: + timeout = float(str(cfg.get("stt_timeout", "10")).replace(",", ".")) + except Exception: + timeout = 10.0 + + try: + proc = subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE if use_silence else subprocess.DEVNULL + ) + + print(f"\n{self('o_STT_Recording')}") + print(f"{self('o_STT_Press_Enter_Stop')}\n") + + stop_reason = self._wait_recording_stop(proc, timeout, use_timeout, use_silence) + + if stop_reason == "timeout": + print(f"\n{self('o_STT_Recording_Timeout')}") + + if proc.poll() is None: + try: + proc.send_signal(signal.SIGINT) + except Exception: + try: + proc.terminate() + except Exception: + pass + + try: + proc.wait(timeout=3) + except Exception: + try: + proc.kill() + except Exception: + pass + + except KeyboardInterrupt: + try: + proc.kill() + except Exception: + pass + return None + + except Exception as e: + print(self("o_Audio_Record_Start_Failed", error=str(e))) + return None + + if not out.exists() or out.stat().st_size <= 0: + return None + + return str(out.resolve()) + + def transcribe_voice_file(self, audio_path: str) -> str: + cfg = self.load_config() + + is_online = bool(cfg.get("is_mic_online", True)) + + if is_online: + return self.transcribe_voice_online(audio_path) + + return self.transcribe_voice_local(audio_path) + + def normalize_stt_text(self, text: str) -> str: + t = str(text or "").strip() + + if not t: + return "__NO_SPEECH__" + + normalized = ( + t.replace("’", "'") + .replace("“", '"') + .replace("”", '"') + .strip() + ) + + low = normalized.lower() + + if "empty_audio" in low: + return "__NO_SPEECH__" + + bad_patterns = [ + "blank_audio", + "music_audio", + "i'm here", + "i am here", + "please upload", + "please provide", + "provide the audio", + "share the audio", + "i can transcribe", + "i will transcribe", + "i'll transcribe", + "upload the audio", + "audio file", + "no audio", + "cannot transcribe", + "can't transcribe", + "no speech", + "no clear speech", + "silence", + "only silence", + "noise", + "only noise", + "background sounds", + "music only", + "please speak when you're ready", + "please speak when you are ready", + "speak when you're ready", + "speak when you are ready", + ] + + if any(p in low for p in bad_patterns): + return "__NO_SPEECH__" + + junk_exact = { + "blank_audio", + "music_audio", + "music only", + "background music", + "speech not detected", + "voice not detected", + "empty audio", + "empty_audio", + "no speech detected", + "no clear speech detected", + } + + if low in junk_exact: + return "__NO_SPEECH__" + + return t + + def transcribe_voice_online(self, audio_path: str) -> str: + import requests + + cfg = self.load_config() + + key = str(cfg.get("open_router_key") or "").strip() + if not key: + print(self("o_OpenRouter_STT_Key_Missing")) + return "" + + model = str( + cfg.get("stt_model_online") + or "openai/gpt-audio-mini" + ).strip() + + try: + audio_b64 = base64.b64encode( + Path(audio_path).read_bytes() + ).decode("utf-8") + + payload = { + "model": model, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": ( + "Speech-to-text only. " + "Return only verbatim transcription. " + "No replies, no explanations, no apologies, no labels. " + "If speech is unclear or missing, return exactly EMPTY_AUDIO." + ) + }, + { + "type": "input_audio", + "input_audio": { + "data": audio_b64, + "format": "wav" + } + } + ] + } + ], + "stream": False + } + + r = requests.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={ + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + "HTTP-Referer": "http://localhost", + "X-Title": "capture-ai", + }, + json=payload, + timeout=120, + ) + + if r.status_code != 200: + print(r.text[:500]) + return "" + + data = r.json() + raw_text = str( + data["choices"][0]["message"]["content"] or "" + ).strip() + + return self.normalize_stt_text(raw_text) + + except Exception as e: + print(self("o_STT_Error", error=str(e))) + return "" + + def transcribe_voice_local(self, audio_path: str) -> str: + cfg = self.load_config() + + whisper_bin = str(cfg.get("whisper_cpp_bin", "") or "").strip() + whisper_model = str(cfg.get("whisper_cpp_model", "") or "").strip() + + if not whisper_bin: + print(self("o_Whisper_Bin_Not_Found", path=self("o_Unknown_Error"))) + return "" + + if not whisper_model: + print(self("o_Whisper_Model_Not_Found", path=self("o_Unknown_Error"))) + return "" + + bin_path = Path(whisper_bin).expanduser() + model_path = Path(whisper_model).expanduser() + + if not bin_path.exists(): + print(self("o_Whisper_Bin_Not_Found", path=bin_path)) + return "" + + if not model_path.exists(): + print(self("o_Whisper_Model_Not_Found", path=model_path)) + return "" + + cmd = [ + str(bin_path), + "-m", + str(model_path), + "-f", + str(audio_path), + "-otxt" + ] + + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True + ) + + if proc.returncode != 0: + print(proc.stderr.strip() or proc.stdout.strip() or self("o_Whisper_Failed")) + return "" + + txt_path = Path(str(audio_path) + ".txt") + + if txt_path.exists(): + text = txt_path.read_text(encoding="utf-8", errors="ignore").strip() + try: + txt_path.unlink() + except Exception: + pass + return self.normalize_stt_text(text) + + return self.normalize_stt_text(proc.stdout.strip()) + + except Exception as e: + print(self("o_STT_Error", error=e)) + return "" + + + def voice_input_flow(self): + self.clear_screen() + print(f"{self('o_Microphone')}") + print(f"{self('o_Type_ESC')}\n") + + audio_path = self.record_voice_to_file() + + if not audio_path: + self._read_input(f"\n{self('o_to_Menu')}") + return + + text = self.transcribe_voice_file(audio_path) + + if not text or text == "__NO_SPEECH__": + print(f"\n{self('o_No_Speech_Detected')}") + self._read_input(f"\n{self('o_to_Menu')}") + return + + if not any( + (v.get("path") if isinstance(v, dict) else v) == audio_path + for v in self.pending_voices + ): + self.pending_voices.append({ + "path": audio_path, + "text": text + }) + + print("\n---") + print(text) + print("---") + + ans = self._read_input(f"\n{self('o_Send_Message')}? (y/n): ").strip().lower() + + if ans not in ("e", "evet", "y", "yes"): + return + + self.append_user_message(text) + + self.clear_screen() + print(f"{self('o_AI_Response')}\n") + + t = threading.Thread(target=self.call_ai, daemon=True) + t.start() + + print(f"\n⏹️ {self('o_Stop_Button')}: esc") + + while t.is_alive(): + try: + r, _, _ = select.select([sys.stdin], [], [], 0.15) + + if r: + cmd = sys.stdin.readline().strip().lower() + + if cmd in ("esc", "stop", "/stop"): + self.stop_ai_generation_cli() + break + + except KeyboardInterrupt: + self.stop_ai_generation_cli() + break + + t.join(timeout=0.2) + + self._read_input(f"\n{self('o_to_Menu')}") + # ---------------- AI ---------------- def _prompt_multiline(self): print(f"{self('o_sub_Send_Message')}") print(f"{self('o_Type_ESC')}\n") - lines = [] - while True: - line = self._read_input() + line = self._read_input(f"{self('o_Send_Message')}: ") - if not lines and self._is_escape_input(line): - return "" + if self._is_escape_input(line): + return "" - if line == "": - if lines: - break - return "" - - lines.append(line) - - return "\n".join(lines).strip() + return line.strip() def append_user_message(self, message: str): messages = self.load_chat_messages() - new_message = {"role": "user", "content": message} + voice_texts = [] + + for v in self.pending_voices: + if isinstance(v, dict): + t = str(v.get("text") or "").strip() + + if t: + voice_texts.append(t) + + final_message = str(message or "").strip() + + if voice_texts: + combined_voice = "\n".join(voice_texts).strip() + + if not final_message: + final_message = combined_voice + + ref_set = set() + selected_refs = sorted([ + i for i in self.selected_ref_indexes + if isinstance(i, int) and 0 <= i < len(messages) + ]) + + for ridx in selected_refs: + ref_set |= self.expand_ref_chain(messages, ridx) + + new_message = { + "role": "user", + "content": final_message + } + + if ref_set: + new_message["used_refs"] = sorted(ref_set) + new_message["refs_groups"] = self.build_refs_groups_cli(messages, selected_refs) + + if self.pending_voices: + new_message["voices"] = [] + + for v in self.pending_voices: + if isinstance(v, dict): + p = v.get("path", "") + text = v.get("text", "") + else: + p = str(v) + text = "" + + pp = Path(str(p)) + + if pp.exists(): + new_message["voices"].append({ + "path": str(pp.resolve()), + "text": str(text or "").strip() + }) if self.pending_images: new_message["images"] = [ @@ -726,17 +2676,21 @@ class ChatCLI: for f in self.pending_files: fp = Path(str(f.get("path", ""))) if fp.exists(): - new_message["files"].append({ + file_obj = { "path": str(fp.resolve()), "name": fp.name, "edit": bool(f.get("edit", False)) - }) + } + + new_message["files"].append(file_obj) messages.append(new_message) self.save_chat_messages(messages) + self.pending_voices.clear() self.pending_images.clear() self.pending_files.clear() + self.selected_ref_indexes.clear() def finalize_ai_response(self, raw_text: str): raw_text = (raw_text or "").strip() @@ -744,7 +2698,24 @@ class ChatCLI: 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} messages = self.load_chat_messages() @@ -754,6 +2725,17 @@ class ChatCLI: } usage = data.get("usage") + + 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 [] + } + if isinstance(usage, dict): bot_msg["usage"] = usage @@ -843,12 +2825,93 @@ class ChatCLI: if saved_images: bot_msg["images"] = saved_images + 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 + messages.append(bot_msg) self.save_chat_messages(messages) return bot_msg + def stream_print_cli(self, text: str): + text = str(text or "") + + if not text: + return + + print(text, end="", flush=True) + + def stop_ai_generation_cli(self): + proc = self.current_ai_process + + if not proc: + return False + + try: + self.ai_cancel_requested = True + + try: + proc.terminate() + except Exception: + pass + + try: + proc.kill() + except Exception: + pass + + self.current_ai_process = None + + print(f"\n⏭️ {self('o_Cancelled')}") + return True + + except Exception: + return False + def call_ai(self): - cmd = [sys.executable, "-u", AI_SCRIPT, str(self.current_chat)] + selected_arg = "" + + try: + messages = self.load_chat_messages() + for msg in reversed(messages): + if isinstance(msg, dict) and msg.get("role") == "user": + used_refs = msg.get("used_refs") + if isinstance(used_refs, list) and used_refs: + selected_arg = ",".join( + str(x) for x in used_refs if isinstance(x, int) + ) + break + except Exception: + selected_arg = "" + + context_mode = self.get_context_mode(self.current_chat) + + cmd = [ + sys.executable, + "-u", + AI_SCRIPT, + str(self.current_chat), + selected_arg or "", + "--context-mode", + context_mode, + ] try: env = os.environ.copy() @@ -856,35 +2919,897 @@ class ChatCLI: env["PYTHONIOENCODING"] = "utf-8" env["PYTHONUNBUFFERED"] = "1" - proc = subprocess.run( + proc = subprocess.Popen( cmd, - capture_output=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, text=False, env=env ) - stdout_text = proc.stdout.decode("utf-8", errors="replace") - stderr_text = proc.stderr.decode("utf-8", errors="replace") + self.current_ai_process = proc + self.ai_cancel_requested = False - stdout_text = fix_mojibake(stdout_text) - stderr_text = fix_mojibake(stderr_text) + if proc.stdout is None or proc.stderr is None: + raise RuntimeError("Popen stdout/stderr PIPE değil (None geldi)") + + decoder = codecs.getincrementaldecoder("utf-8")("replace") + partial = "" + stderr_chunks = [] + json_mode = False + + out_fd = proc.stdout.fileno() + err_fd = proc.stderr.fileno() + + stream_buffer = [] + stream_lock = threading.Lock() + stream_timer_active = True + stream_failed = False + + STREAM_FLUSH_SECONDS = 0.01 + STREAM_CHARS_PER_FLUSH = 8 + last_flush = time.time() + + def flush_stream_buffer(force=False): + nonlocal stream_timer_active + + if (not stream_timer_active) or stream_failed: + return + + if json_mode: + return + + with stream_lock: + pending = "".join(stream_buffer) + + if not pending: + return + + if force: + chunk = pending + stream_buffer.clear() + else: + chunk = pending[:STREAM_CHARS_PER_FLUSH] + rest = pending[STREAM_CHARS_PER_FLUSH:] + stream_buffer.clear() + + if rest: + stream_buffer.append(rest) + + if chunk: + self.stream_print_cli(chunk) + + while True: + rlist, _, _ = select.select([out_fd, err_fd], [], [], 0.1) + + if out_fd in rlist: + data = os.read(out_fd, 4096) + + if data: + text = decoder.decode(data) + + if text: + partial += text + + stripped = partial.lstrip() + + # JSON başladıysa ekrana basma. + # Çünkü ai.py final result JSON döndürüyor. + if stripped.startswith("{") or stripped.startswith("["): + json_mode = True + else: + with stream_lock: + stream_buffer.append(text) + + else: + break + + if err_fd in rlist: + data = os.read(err_fd, 4096) + + if data: + stderr_chunks.append( + data.decode("utf-8", errors="replace") + ) + + now = time.time() + if now - last_flush >= STREAM_FLUSH_SECONDS: + flush_stream_buffer() + last_flush = now + + if proc.poll() is not None: + break + + # decoder içinde kalan UTF-8 parçasını al + tail = decoder.decode(b"", final=True) + if tail: + partial += tail + + if not json_mode: + with stream_lock: + stream_buffer.append(tail) + + flush_stream_buffer(force=True) + stream_timer_active = False + + # Kalan stdout varsa oku + try: + rest = os.read(out_fd, 65536) + if rest: + text = decoder.decode(rest) + partial += text + except Exception: + pass + + try: + err_rest = os.read(err_fd, 65536) + if err_rest: + stderr_chunks.append( + err_rest.decode("utf-8", errors="replace") + ) + except Exception: + pass + + proc.wait() + + self.current_ai_process = None + + stdout_text = fix_mojibake(partial) + stderr_text = fix_mojibake("".join(stderr_chunks)) + + if self.ai_cancel_requested: + self.handle_ai_error(self("o_Cancelled")) + return None if proc.returncode != 0: - err = (stderr_text or stdout_text or "Bilinmeyen hata").strip() + err = (stderr_text or stdout_text or self("o_Unknown_Error")).strip() self.handle_ai_error(err) return None - return self.finalize_ai_response(stdout_text) + # Eğer stdout JSON ise finalize et. + # Eğer stdout gerçek streaming text ise finalize raw text olarak kaydeder. + bot_msg = self.finalize_ai_response(stdout_text) + + # JSON modda ekrana hiç yazmadık; son cevabı burada göster. + # Normal text stream modda zaten yazıldı, tekrar basmayalım. + if json_mode and bot_msg: + self.print_last_bot_message(bot_msg) + + return bot_msg except Exception as e: + self.current_ai_process = None self.handle_ai_error(str(e)) return None def handle_ai_error(self, error_text): messages = self.load_chat_messages() - messages.append({"role": "bot", "content": f"❌ Hata:\n{error_text}"}) + + messages.append({ + "role": "bot", + "error_key": "o_Error_With_Detail", + "error_params": { + "error": str(error_text or self("o_Unknown_Error")).strip() + } + }) + self.save_chat_messages(messages) - print(f"\n❌ Hata:\n{error_text}") + + print() + print(self("o_Error_With_Detail", error=str(error_text or self("o_Unknown_Error")).strip())) + + def render_message_text_cli(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")) + 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 extract_links_from_text(self, text: str) -> list[str]: + return re.findall(r"https?://[^\s\]\)\"'>]+", str(text or "")) + + + def get_message_by_number(self, number: int): + messages = self.load_chat_messages() + idx = number - 1 + + if not (0 <= idx < len(messages)): + return None, None + + return idx, messages[idx] + + def build_header_lines(self): + self.active_model = self.get_chat_model(self.current_chat) + model_entry = self.get_chat_model_entry(self.current_chat) + + model_label = model_entry["id"] + if model_entry.get("local"): + model_label += f" [{self('o_Local').upper()}]" + else: + model_label += f" [{self('o_Online').upper()}]" + + labels = [ + self("o_Active_Chat"), + self("o_Active_Model"), + self("o_Language"), + ] + + max_len = max(len(x) for x in labels) + + return [ + "=" * 60, + f"{labels[0].ljust(max_len)} : {self.current_chat.stem}", + f"{labels[1].ljust(max_len)} : {model_label}", + f"{labels[2].ljust(max_len)} : {self.get_ui_language()}", + "=" * 60, + ] + + def build_chat_history_lines(self): + messages = self.load_chat_messages() + lines = [] + + lines.append(f"{self('o_Chats')} / {self('o_History')}") + lines.append("=" * 60) + + if not messages: + lines.append("-") + return lines + + def web_status_label(status: str) -> str: + status = str(status or "").strip() + + if status == "searched": + return f"✅ {self('o_Web_Search_Searched')}" + if status == "searching": + return f"🔎 {self('o_Web_Searching')}" + if status == "cancelled": + return f"⏭️ {self('o_Web_Search_Cancelled')}" + if status == "pending": + return f"⏳ {self('o_Web_Search_Request')}" + + return status or "-" + + + def apply_status_label(status: str) -> str: + status = str(status or "").strip() + + if status == "ok": + return f"✅ {self('o_Apply_Applied')}" + if status == "fail": + return f"❌ {self('o_Apply_Failed')}" + if status == "cancel": + return f"⏭️ {self('o_Cancelled')}" + + return status or "-" + + for i, msg in enumerate(messages, 1): + role = str(msg.get("role") or "").upper() + text = self.render_message_text_cli(msg).strip() + + if len(text) > 240: + text = text[:240] + "..." + + has_files = bool(msg.get("generated_files")) + has_images = bool(msg.get("images")) + has_links = bool(self.extract_links_from_text(text)) + has_voices = isinstance(msg.get("voices"), list) and bool(msg.get("voices")) + + web_req = msg.get("web_search_request") + apply_status = msg.get("apply_status") + + has_web = isinstance(web_req, dict) + has_apply = isinstance(apply_status, dict) + + extra = [] + if has_files: + extra.append("download") + if has_images: + extra.append("image") + if has_links: + extra.append("links") + if has_voices: + extra.append("voice") + if has_web: + status = str(web_req.get("status") or "") + extra.append(f"web:{status}") + + if has_apply: + status = str(apply_status.get("status") or "") + extra.append(f"apply:{status}") + + if (i - 1) in self.selected_ref_indexes: + extra.append("ref") + + suffix = f" [{' | '.join(extra)}]" if extra else "" + + lines.append("") + lines.append(f"/{i} [{role}]{suffix}") + lines.extend((text if text else "-").splitlines()) + + if has_web: + query = str(web_req.get("query") or "").strip() + status = str(web_req.get("status") or "").strip() + err = str(web_req.get("err") or "").strip() + + lines.append("") + lines.append(f" 🌐 {self('o_Web_Search_Request')}") + lines.append(f" {self('o_Search_Query')}: {query or '-'}") + lines.append(f" {self('o_Status')}: {web_status_label(status)}") + + if err: + lines.append(f" ❌ {err}") + + if has_apply: + status = str(apply_status.get("status") or "").strip() + err = str(apply_status.get("err") or "").strip() + files = apply_status.get("files") + + lines.append("") + lines.append(f" 📝 {self('o_File_Changes')}") + lines.append(f" {self('o_Status')}: {apply_status_label(status)}") + + if isinstance(files, list) and files: + lines.append(f" {self('o_Files')}:") + for f in files: + lines.append(f" - {f}") + + if err: + lines.append(f" ❌ {err}") + + lines.append("") + lines.append(f"{self('o_Commands')}:") + lines.append("/number ref") + lines.append("/clear refs") + lines.append("/number copy") + lines.append("/number regen") + lines.append("/number open") + lines.append("/number open 2") + lines.append("/number links") + lines.append("/number link 2") + lines.append("/number download") + lines.append("/number download all") + lines.append("/number folder") + lines.append("/back") + + return lines + + def print_chat_history_curses(self): + def _screen(stdscr): + curses.curs_set(1) + stdscr.keypad(True) + + offset = None + command = "" + + while True: + stdscr.erase() + h, w = stdscr.getmaxyx() + + header = self.build_header_lines() + history = self.build_chat_history_lines() + + for y, line in enumerate(header[:h]): + stdscr.addnstr(y, 0, line, w - 1) + + top = len(header) + input_h = 2 + body_h = max(1, h - top - input_h) + + max_offset = max(0, len(history) - body_h) + + if offset is None: + offset = max_offset + else: + offset = max(0, min(offset, max_offset)) + + visible = history[offset:offset + body_h] + + for i, line in enumerate(visible): + stdscr.addnstr(top + i, 0, line, w - 1) + + input_y = h - 1 + stdscr.addnstr(input_y, 0, f"{self('o_Selection')}: {command}", w - 1) + stdscr.refresh() + + ch = stdscr.getch() + + if ch == curses.KEY_UP: + offset -= 1 + elif ch == curses.KEY_DOWN: + offset += 1 + elif ch in (curses.KEY_PPAGE,): + offset -= body_h + elif ch in (curses.KEY_NPAGE,): + offset += body_h + elif ch in (10, 13): + cmd = command.strip() + command = "" + + # Boş Enter basılırsa history ekranında kal + if not cmd: + continue + + # Sadece /back gibi komutlarda ana menüye dön + if cmd in ("/back", "back", "esc", ":q"): + return None + + # Komutu curses dışına taşı + return { + "cmd": cmd + } + + elif ch in (curses.KEY_BACKSPACE, 127, 8): + command = command[:-1] + elif 32 <= ch <= 126: + command += chr(ch) + + try: + result = curses.wrapper(_screen) + except curses.error: + result = None + + if isinstance(result, dict): + cmd = str(result.get("cmd") or "").strip() + + if not cmd: + return + + low = cmd.lower().strip() + + if low in ("clear refs", "/clear refs", "clear ref", "/clear ref"): + self.selected_ref_indexes.clear() + self.print_chat_history_curses() + return + + is_ref_cmd = bool(re.match(r"^/\d+\s+ref$", cmd, re.I)) + + handled = self.handle_number_command(cmd) + + if is_ref_cmd and handled: + self.print_chat_history_curses() + return + + if handled: + self._read_input(f"\n{self('o_to_Menu')}") + + def print_chat_history(self): + self.print_chat_history_curses() + + def collect_message_paths(self, msg: dict) -> list[dict]: + items = [] + + files = msg.get("generated_files") + if isinstance(files, list): + for f in files: + if not isinstance(f, dict): + continue + + path = str(f.get("path") or "").strip() + if not path: + continue + + p = Path(path).expanduser() + items.append({ + "type": "file", + "path": str(p), + "name": str(f.get("name") or p.name) + }) + + images = msg.get("images") + if isinstance(images, list): + for img in images: + p = Path(str(img)).expanduser() + items.append({ + "type": "image", + "path": str(p), + "name": p.name + }) + + if msg.get("image"): + p = Path(str(msg.get("image"))).expanduser() + items.append({ + "type": "image", + "path": str(p), + "name": p.name + }) + + voices = msg.get("voices") + if isinstance(voices, list): + for v in voices: + if isinstance(v, dict): + p = Path(str(v.get("path") or "")).expanduser() + else: + p = Path(str(v)).expanduser() + + items.append({ + "type": "voice", + "path": str(p), + "name": p.name + }) + + return [ + item for item in items + if Path(item["path"]).exists() and Path(item["path"]).is_file() + ] + + + def item_icon(self, item_type: str) -> str: + if item_type == "image": + return "🖼️" + if item_type == "voice": + return "🎙️" + return "📄" + + def expand_ref_chain(self, messages: list, idx: int, seen=None) -> set[int]: + if seen is None: + seen = set() + + if idx in seen: + return seen + + seen.add(idx) + + msg = messages[idx] if 0 <= idx < len(messages) else None + if not isinstance(msg, dict): + return seen + + used = msg.get("used_refs") or [] + if isinstance(used, list): + for j in used: + if isinstance(j, int) and 0 <= j < len(messages): + self.expand_ref_chain(messages, j, seen) + + return seen + + + def pack_preview_item(self, rmsg: dict) -> dict: + rrole = str(rmsg.get("role") or "").strip() + rtext = str(rmsg.get("content") or "").strip() + + has_img = bool( + rmsg.get("image") or + (isinstance(rmsg.get("images"), list) and rmsg.get("images")) + ) + + if not rtext and has_img: + rtext = f"[{self('o_Image')}]" + elif has_img: + rtext = rtext + f" + [{self('o_Image')}]" + + if len(rtext) > 220: + rtext = rtext[:220] + "..." + + return { + "role": rrole, + "text": rtext + } + + + def build_refs_groups_cli(self, messages: list, ref_indexes: list[int]) -> list[dict]: + groups = [] + + for ridx in ref_indexes: + if not (0 <= ridx < len(messages)): + continue + + rmsg = messages[ridx] + if not isinstance(rmsg, dict): + continue + + items = [] + + rg = rmsg.get("refs_groups") + if isinstance(rg, list) and rg: + for g in rg: + its = g.get("items") if isinstance(g, dict) else None + if isinstance(its, list): + for it in its: + if isinstance(it, dict) and it.get("text"): + items.append({ + "role": it.get("role", ""), + "text": it.get("text", "") + }) + else: + rp = rmsg.get("refs_preview") + if isinstance(rp, list) and rp: + for it in rp: + if isinstance(it, dict) and it.get("text"): + items.append({ + "role": it.get("role", ""), + "text": it.get("text", "") + }) + + items.append(self.pack_preview_item(rmsg)) + groups.append({"items": items}) + + return groups + + def find_regen_target_index(self, idx: int, messages: list): + if not (0 <= idx < len(messages)): + return None + + msg = messages[idx] + + if str(msg.get("role")) == "user": + return idx + + for i in range(idx - 1, -1, -1): + if str(messages[i].get("role")) == "user": + return i + + return None + + def clear_refs_command(self, cmd: str) -> bool: + cmd = str(cmd or "").strip().lower() + + if cmd not in ("clear refs", "/clear refs", "clear ref", "/clear ref"): + return False + + self.selected_ref_indexes.clear() + return True + + def handle_number_command(self, cmd: str) -> bool: + cmd = str(cmd or "").strip() + + m = re.match(r"^/(\d+)\s+(open|links|link|download|folder|copy|regen|ref)(?:\s+(\d+|all))?$", cmd, re.I) + if not m: + return False + + number = int(m.group(1)) + action = m.group(2).lower() + arg = str(m.group(3) or "").strip().lower() + + idx, msg = self.get_message_by_number(number) + + if msg is None: + print(f"\n{self('o_Invalid_Value')}") + return True + + if action == "ref": + if idx in self.selected_ref_indexes: + self.selected_ref_indexes.remove(idx) + else: + self.selected_ref_indexes.add(idx) + + count = len(self.selected_ref_indexes) + + if count: + print(f"\n✔ {count} {self('o_Reference_Message')}") + else: + print(f"\n✔ {self('o_Clear_Selection')}") + + return True + + if action == "copy": + text = self.render_message_text_cli(msg).strip() + + if not text: + print(f"\n{self('o_No_Text_To_Copy')}") + return True + + copied = False + + try: + if shutil.which("wl-copy"): + subprocess.run(["wl-copy"], input=text, text=True, check=True) + copied = True + elif shutil.which("xclip"): + subprocess.run(["xclip", "-selection", "clipboard"], input=text, text=True, check=True) + copied = True + elif shutil.which("xsel"): + subprocess.run(["xsel", "--clipboard", "--input"], input=text, text=True, check=True) + copied = True + except Exception: + copied = False + + if copied: + print(f"\n✔ {self('o_Copy')}") + else: + print() + print(text) + + return True + + if action == "regen": + messages = self.load_chat_messages() + + target_idx = self.find_regen_target_index(idx, messages) + + if target_idx is None: + print(f"\n{self('o_No_User_Message_For_Regen')}") + return True + + keep = messages[:target_idx + 1] + + self.save_chat_messages(keep) + + self.clear_screen() + print(f"{self('o_AI_Response')}\n") + + self.call_ai() + + self._read_input(f"\n{self('o_to_Menu')}") + return True + + if action in ("links", "link"): + text = self.render_message_text_cli(msg) + links = self.extract_links_from_text(text) + + req = msg.get("web_search_request") + if isinstance(req, dict): + sources = req.get("sources") + if isinstance(sources, list): + for s in sources: + if isinstance(s, str): + links += self.extract_links_from_text(s) + elif isinstance(s, dict): + for key in ("url", "link"): + val = s.get(key) + if isinstance(val, str): + links.append(val) + + links = list(dict.fromkeys(links)) + + if action == "link" and arg.isdigit(): + n = int(arg) - 1 + if 0 <= n < len(links): + webbrowser.open(links[n]) + return True + + if not links: + print(f"\n{self('o_No_Links')}") + return True + + print() + for i, link in enumerate(links, 1): + print(f"{i}) {link}") + + sel = self._read_input(f"\n{self('o_Open_Link_Number_Or_Enter')}: ").strip() + if sel.isdigit(): + n = int(sel) - 1 + if 0 <= n < len(links): + webbrowser.open(links[n]) + + return True + if action == "download": + downloadable = self.collect_message_paths(msg) + + if not downloadable: + print(f"\n{self('o_No_Downloadable_Items')}") + return True + + dest_dir = Path.home() / "Downloads" + dest_dir.mkdir(parents=True, exist_ok=True) + + def copy_one(item): + src = Path(item["path"]).expanduser() + dest = dest_dir / src.name + + if dest.exists(): + stem = src.stem + suffix = src.suffix + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + dest = dest_dir / f"{stem}-{stamp}{suffix}" + + shutil.copy2(src, dest) + return dest + + if arg == "all": + copied = [] + for item in downloadable: + copied.append(copy_one(item)) + + print() + for p in copied: + print(f"✔ {p}") + + return True + + print() + for i, item in enumerate(downloadable, 1): + print(f"{i}) {self.item_icon(item['type'])} {item['name']}") + + if arg.isdigit(): + sel = arg + else: + sel = self._read_input(f"\n{self('o_Selection')}: ").strip() + + if not sel.isdigit(): + return True + + n = int(sel) - 1 + if not (0 <= n < len(downloadable)): + return True + + dest = copy_one(downloadable[n]) + + print(f"\n✔ {dest}") + return True + + if action == "open": + paths = self.collect_message_paths(msg) + + if not paths: + print(f"\n{self('o_No_Openable_Items')}") + return True + + if arg.isdigit(): + n = int(arg) - 1 + if not (0 <= n < len(paths)): + return True + + path = Path(paths[n]["path"]).expanduser() + subprocess.Popen(["xdg-open", str(path)]) + return True + + if len(paths) == 1: + path = Path(paths[0]["path"]).expanduser() + subprocess.Popen(["xdg-open", str(path)]) + return True + + print() + for i, item in enumerate(paths, 1): + print(f"{i}) {self.item_icon(item['type'])} {item['name']}") + + sel = self._read_input(f"\n{self('o_Selection')}: ").strip() + + if not sel.isdigit(): + return True + + n = int(sel) - 1 + if not (0 <= n < len(paths)): + return True + + path = Path(paths[n]["path"]).expanduser() + subprocess.Popen(["xdg-open", str(path)]) + return True + + if action == "folder": + paths = self.collect_message_paths(msg) + + if not paths: + print(f"\n{self('o_No_Openable_Folder')}") + return True + + if arg.isdigit(): + n = int(arg) - 1 + if not (0 <= n < len(paths)): + return True + + folder = Path(paths[n]["path"]).expanduser().parent + subprocess.Popen(["xdg-open", str(folder)]) + return True + + folder = Path(paths[0]["path"]).expanduser().parent + subprocess.Popen(["xdg-open", str(folder)]) + return True + + return False def print_last_bot_message(self, bot_msg: dict): cfg = self.load_config() @@ -892,13 +3817,13 @@ class ChatCLI: text = str(bot_msg.get("content") or "").strip() if text: - print("\nAssistant:") + print(f"\n{self('o_Assistant')}:") print(text) print() imgs = bot_msg.get("images") if isinstance(imgs, list) and imgs: - print("Oluşan görseller:") + print(f"{self('o_Generated_Images')}:") for p in imgs: print(f"- {p}") print() @@ -908,7 +3833,19 @@ class ChatCLI: p = int(usage.get("prompt_tokens", 0) or 0) c = int(usage.get("completion_tokens", 0) or 0) t = int(usage.get("total_tokens", 0) or 0) - print(f"[Input tokens: {p} | Output tokens: {c} | Total tokens: {t}]") + + print(f"[{self('o_Input_Tokens')}: {p} | {self('o_Output_Tokens')}: {c} | {self('o_Total_Tokens')}: {t}]") + + show_token_value = bool(cfg.get("show_token_value", False)) + if show_token_value: + try: + token_value = float(str(cfg.get("token_value", "2.0")).replace(",", ".")) + except Exception: + token_value = 2.0 + + total_price = t * token_value / 1_000_000 + print(f"[{self('o_Total_Token_Price')}: ${total_price:.6f}]") + print() def send_message_flow(self): @@ -921,16 +3858,49 @@ class ChatCLI: self.clear_screen() print(f"{self('o_AI_Response')}\n") - bot_msg = self.call_ai() - if bot_msg: - self.print_last_bot_message(bot_msg) - self._read_input(f"{self('o_to_Menu')}") + t = threading.Thread(target=self.call_ai, daemon=True) + t.start() + + print(f"\n⏹️ {self('o_Stop_Button')}: esc") + + while t.is_alive(): + try: + r, _, _ = select.select([sys.stdin], [], [], 0.15) + + if r: + cmd = sys.stdin.readline().strip().lower() + + if cmd in ("esc", "stop", "/stop"): + self.stop_ai_generation_cli() + break + + except KeyboardInterrupt: + self.stop_ai_generation_cli() + break + + t.join(timeout=0.2) + + # AI cevabından sonra web search isteği oluştuysa hemen aynı ekranda göster. + if self.handle_pending_web_search_requests(): + return + + # Apply isteği oluştuysa onu da hemen aynı ekranda göster. + if self.handle_pending_apply_requests(): + return + + self._read_input(f"\n{self('o_to_Menu')}") # ---------------- MENUS ---------------- def print_header(self): self.active_model = self.get_chat_model(self.current_chat) + model_entry = self.get_chat_model_entry(self.current_chat) + model_label = model_entry["id"] + if model_entry.get("local"): + model_label += f" [{self('o_Local').upper()}]" + else: + model_label += f" [{self('o_Online').upper()}]" print("\n" + "=" * 60) labels = [ self('o_Active_Chat'), @@ -941,20 +3911,50 @@ class ChatCLI: max_len = max(len(x) for x in labels) print(f"{labels[0].ljust(max_len)} : {self.current_chat.stem}") - print(f"{labels[1].ljust(max_len)} : {self.active_model}") + print(f"{labels[1].ljust(max_len)} : {model_label}") print(f"{labels[2].ljust(max_len)} : {self.get_ui_language()}") print("=" * 60) - if self.pending_images or self.pending_files: + if self.selected_ref_indexes: + ref_numbers = [ + str(i + 1) + for i in sorted(self.selected_ref_indexes) + ] + print(f"{self('o_Reference_Messages')} [{', '.join(ref_numbers)}]") + print("-" * 60) + + if self.pending_voices or self.pending_images or self.pending_files: print(f"{self('o_Pending_Attachments')}:") n = 101 + for v in self.pending_voices: + if isinstance(v, dict): + p = v.get("path", "") + text = str(v.get("text") or "").strip() + else: + p = str(v) + text = "" + + preview = text if text else Path(p).name + + if len(preview) > 70: + preview = preview[:70] + "..." + + print(f"{n}) [🎙️ {self('o_Voice')}] {preview} ({self('o_Delete')})") + n += 1 + for p in self.pending_images: print(f"{n}) [{self('o_image')}] {Path(p).name} ({self('o_Delete')})") n += 1 for f in self.pending_files: - print(f"{n}) [{self('o_file')}] {f.get('name')} ({self('o_Delete')})") + edit_mark = "📝" if bool(f.get("edit")) else "📄" + + print( + f"{n}) [{edit_mark} {self('o_file')}] " + f"{f.get('name')} ({self('o_Delete')})" + ) + n += 1 print("-" * 60) @@ -991,7 +3991,7 @@ class ChatCLI: elif choice == "3": chats = self.list_chat_files() if not chats: - print("Hiç chat yok.") + print(self("o_No_Chats")) continue print(f"\n{self('o_Delete_Chat')}:") @@ -1014,12 +4014,12 @@ class ChatCLI: if confirm == "e": self.delete_chat(chats[idx]) - self._read_input(f"{self('o_to_Menu')}") + self._read_input(f"\n{self('o_to_Menu')}") elif choice == "4": chats = self.list_chat_files() if not chats: - print("Hiç chat yok.") + print(self("o_No_Chats")) continue print(f"\n{self('o_Rename_Chat')}:") @@ -1039,11 +4039,11 @@ class ChatCLI: new_name = input(f"{self('o_New_Name')} ").strip() self.rename_chat(chats[idx], new_name) - self._read_input(f"{self('o_to_Menu')}") + self._read_input(f"\n{self('o_to_Menu')}") elif choice == "5": self.toggle_pin_current_chat() - self._read_input(f"{self('o_to_Menu')}") + self._read_input(f"\n{self('o_to_Menu')}") elif choice == "6": return @@ -1064,10 +4064,15 @@ class ChatCLI: if choice == "1": print(f"\n{self('o_AI_Models')}:") + active = self.get_chat_model(self.current_chat) + for i, m in enumerate(models, 1): - mark = "*" if m == active else " " - print(f"{i}) {mark} {m}") + model_id = str(m.get("id") or "").strip() + is_local = bool(m.get("local", False)) + mark = "*" if model_id == active else " " + local_label = f" [{self('o_Local')}]" if is_local else "" + print(f"{i}) {mark} {model_id}{local_label}") sel = self._read_input(f"\n{self('o_Selection')}: ").strip() @@ -1078,21 +4083,39 @@ class ChatCLI: if not (0 <= idx < len(models)): continue - self.set_chat_model(self.current_chat, models[idx]) - print(f"\n{self('o_Active_Model')}: {models[idx]}") - self._read_input(f"{self('o_to_Menu')}") + selected = models[idx] + model_id = str(selected.get("id") or "").strip() + is_local = bool(selected.get("local", False)) + if not model_id: + continue + + self.set_chat_model(self.current_chat, model_id, is_local) + print(f"\n{self('o_Active_Model')}: {model_id}") + self._read_input(f"\n{self('o_to_Menu')}") elif choice == "2": - model_id = input(f"\n{self('o_Add_Model')}(exp: openai/gpt-4.1-mini): ").strip() + model_id = self._read_input( + f"\n{self('o_Add_Model')} (exp: openai/gpt-4.1-mini): " + ).strip() if self._is_escape_input(model_id): continue - if model_id: - self.set_chat_model(self.current_chat, model_id) - print(f"\n✔ {self('o_Add_Model')}: {model_id}") - self._read_input(f"{self('o_to_Menu')}") + if not model_id: + continue + + is_local_raw = self._read_input( + f"{self('o_Local_Model')}? ({self('o_Yes')}/{self('o_No')}): " + ).strip().lower() + + is_local = is_local_raw in ("e", "evet", "y", "yes", "1", "true", "on") + + self.set_chat_model(self.current_chat, model_id, is_local) + + local_label = f" [{self('o_Local')}]" if is_local else "" + print(f"\n✔ {self('o_Add_Model')}: {model_id}{local_label}") + self._read_input(f"\n{self('o_to_Menu')}") elif choice == "3": cfg2 = self.load_config() @@ -1104,9 +4127,14 @@ class ChatCLI: continue print(f"\n{self('o_Delete_Model')}:") + active = self.get_chat_model(self.current_chat) + for i, m in enumerate(models2, 1): - mark = "*" if m == self.get_chat_model(self.current_chat) else " " - print(f"{i}) {mark} {m}") + model_id = str(m.get("id") or "").strip() + is_local = bool(m.get("local", False)) + mark = "*" if model_id == active else " " + local_label = f" [{self('o_Local')}]" if is_local else "" + print(f"{i}) {mark} {model_id}{local_label}") sel = self._read_input(f"\n{self('o_Selection')}: ").strip() @@ -1117,30 +4145,57 @@ class ChatCLI: if not (0 <= idx < len(models2)): continue - model_id = models2[idx] + selected = models2[idx] + model_id = str(selected.get("id") or "").strip() - confirm = input(f'{self("o_Delete")}("{model_id}")? (e/h): ').strip().lower() + if not model_id: + continue - if confirm == "e": - models2 = [m for m in models2 if m != model_id] - cfg2["ai_models"] = models2 + confirm = self._read_input( + f'{self("o_Delete")}("{model_id}")? ({self("o_Yes")}/{self("o_No")}): ' + ).strip().lower() - chat_models = cfg2.get("chat_models", {}) - if not isinstance(chat_models, dict): - chat_models = {} + if confirm not in ("e", "evet", "y", "yes"): + continue - new_default = str(models2[0]) if models2 else None - for k, v in list(chat_models.items()): - if v == model_id: - chat_models[k] = new_default + models2 = [ + m for m in models2 + if str(m.get("id") or "").strip() != model_id + ] - cfg2["chat_models"] = chat_models + if not models2: + models2 = [{ + "id": "google/gemini-2.5-flash-image", + "local": False + }] - self.save_config(cfg2) - self.active_model = self.get_chat_model(self.current_chat) + cfg2["ai_models"] = models2 - print(f"\n✔ {self('o_Delete_Model')}: {model_id}") - self._read_input(f"{self('o_to_Menu')}") + new_default = { + "id": str(models2[0].get("id") or ""), + "local": bool(models2[0].get("local", False)) + } + + chat_models = cfg2.get("chat_models", {}) + if not isinstance(chat_models, dict): + chat_models = {} + + for chat_name, value in list(chat_models.items()): + if isinstance(value, dict): + old_id = str(value.get("id") or "").strip() + else: + old_id = str(value or "").strip() + + if old_id == model_id: + chat_models[chat_name] = dict(new_default) + + cfg2["chat_models"] = chat_models + self.save_config(cfg2) + + self.active_model = self.get_chat_model(self.current_chat) + + print(f"\n✔ {self('o_Delete_Model')}: {model_id}") + self._read_input(f"\n{self('o_to_Menu')}") elif choice == "4": return @@ -1149,11 +4204,16 @@ class ChatCLI: while True: self.clear_screen() cfg = self.load_config() - show_usage = bool(cfg.get("show_usage", True)) - print("\nPersonalization") + show_usage = bool(cfg.get("show_usage", True)) + show_token_value = bool(cfg.get("show_token_value", False)) + token_value = str(cfg.get("token_value", "2.0") or "2.0") + + print(f"\n{self('o_Personalization')}") print(f"1) {self('o_Token_Usage')}: {'✅' if show_usage else '❌'}") - print(f"2) {self('o_Go_Back')}") + print(f"2) {self('o_Use_Token_Value')}: {'✅' if show_token_value else '❌'}") + print(f"3) {self('o_Token_Value')}: {token_value}") + print(f"4) {self('o_Go_Back')}") choice = self._read_input(f"\n{self('o_Selection')}: ").strip() @@ -1162,6 +4222,26 @@ class ChatCLI: self.save_config(cfg) elif choice == "2": + cfg["show_token_value"] = not show_token_value + self.save_config(cfg) + + elif choice == "3": + new_value = self._read_input(f"{self('o_New_Value')} ").strip() + + if self._is_escape_input(new_value): + continue + + try: + val = float(new_value.replace(",", ".")) + if val < 0: + raise ValueError + cfg["token_value"] = str(val) + self.save_config(cfg) + except Exception: + print(f"\n{self('o_Invalid_Value')}") + self._read_input(f"\n{self('o_to_Menu')}") + + elif choice == "4": return def menu_ai_settings(self): @@ -1170,10 +4250,13 @@ class ChatCLI: cfg = self.load_config() force_ui_language = bool(cfg.get("force_ui_language", False)) + ask_for_web_search = bool(cfg.get("ask_for_web_search", True)) - print("\nAI Settings") - print(f"1) {self('o_Force_Language_Text')}({self('o_Force_Language_Hint')}): {'✅' if force_ui_language else '❌'}") - print(f"2) {self('o_Go_Back')}") + print(f"\n{self('o_AI_Settings')}") + print(f"1) {self('o_Force_Language_Text')} ({self('o_Force_Language_Hint')}): {'✅' if force_ui_language else '❌'}") + print(f"2) {self('o_Ask_Web_Search')}: {'✅' if ask_for_web_search else '❌'}") + print(f"3) {self('o_AI_Providers')}") + print(f"4) {self('o_Go_Back')}") choice = self._read_input(f"\n{self('o_Selection')}: ").strip() @@ -1182,6 +4265,13 @@ class ChatCLI: self.save_config(cfg) elif choice == "2": + cfg["ask_for_web_search"] = not ask_for_web_search + self.save_config(cfg) + + elif choice == "3": + self.menu_local_providers() + + elif choice == "4": return def menu_settings(self): @@ -1190,11 +4280,17 @@ class ChatCLI: print(f"\n{self('o_Settings')}") print(f"1) {self('o_AI_Model')}") print(f"2) {self('o_OpenRouter_API_Key')}") - print(f"3) {self('o_Response_Style')}") - print(f"4) {self('o_Personalization')}") - print(f"5) {self('o_Language')}") - print(f"6) {self('o_AI_Settings')}") - print(f"7) {self('o_Go_Back')}") + print(f"3) {self('o_Tavily_API_Key')}") + print(f"4) {self('o_Response_Style')}") + print(f"5) {self('o_Personalization')}") + print(f"6) {self('o_Language')}") + print(f"7) {self('o_AI_Settings')}") + print(f"8) {self('o_Prompt_Chooser')}") + print(f"9) {self('o_Context_Mode_Text')} ({self('o_Context_RAG')} / {self('o_Context_Direct')})") + print(f"10) {self('o_RAG_Settings')}") + print(f"11) {self('o_STT_Settings')}") + print(f"12) {self('o_Go_Back')}") + choice = self._read_input(f"\n{self('o_Selection')}: ").strip() @@ -1205,9 +4301,12 @@ class ChatCLI: elif choice == "2": cfg = self.load_config() current = str(cfg.get("open_router_key", "") or "") - print(f"\n{self('o_OpenRouter_API_Key')}:\n{current}\n") + + print(f"\n{self('o_OpenRouter_API_Key')}:") + print("********" if current else "-") print(f"\n{self('o_Type_ESC')}") - new_key = input(f"\n{self('o_OpenRouter_Placeholder')}: ").strip() + + new_key = self._read_input(f"\n{self('o_OpenRouter_Placeholder')}: ").strip() if self._is_escape_input(new_key): continue @@ -1215,10 +4314,29 @@ class ChatCLI: if new_key: cfg["open_router_key"] = new_key self.save_config(cfg) - print(f"\n✔ {self('o_Saved')}({self('o_OpenRouter_API_Key')})") - self._read_input(f"{self('o_to_Menu')}") + print(f"\n✔ {self('o_Saved')} ({self('o_OpenRouter_API_Key')})") + self._read_input(f"\n{self('o_to_Menu')}") elif choice == "3": + cfg = self.load_config() + current = str(cfg.get("tavily_api_key", "") or "") + + print(f"\n{self('o_Tavily_API_Key')}:") + print("********" if current else "-") + print(f"\n{self('o_Type_ESC')}") + + new_key = self._read_input(f"\n{self('o_Tavily_API_Key')}: ").strip() + + if self._is_escape_input(new_key): + continue + + if new_key: + cfg["tavily_api_key"] = new_key + self.save_config(cfg) + print(f"\n✔ {self('o_Saved')} ({self('o_Tavily_API_Key')})") + self._read_input(f"\n{self('o_to_Menu')}") + + elif choice == "4": cfg = self.load_config() current = str(cfg.get("response_style", "") or "") print(f"\n{self('o_Response_Style')}:\n{current}\n") @@ -1233,13 +4351,13 @@ class ChatCLI: cfg["response_style"] = new_style self.save_config(cfg) print(f"\n✔ {self('o_Saved')}({self('o_Response_Style')})") - self._read_input(f"{self('o_to_Menu')}") + self._read_input(f"\n{self('o_to_Menu')}") - elif choice == "4": - self.menu_personalization() - elif choice == "5": + self.menu_personalization() + + elif choice == "6": self.clear_screen() current = self.get_ui_language() @@ -1269,54 +4387,87 @@ class ChatCLI: self.set_ui_language(new_lang) print(f"\n✔ {self('o_Saved')} ({self('o_Language')}): {new_lang}") - self._read_input(f"{self('o_to_Menu')}") - - elif choice == "6": - self.menu_ai_settings() + self._read_input(f"\n{self('o_to_Menu')}") elif choice == "7": + self.menu_ai_settings() + + elif choice == "8": + self.menu_prompt_chooser() + + elif choice == "9": + self.menu_context_mode() + + elif choice == "10": + self.menu_rag_settings() + + elif choice == "11": + self.menu_stt_settings() + + elif choice == "12": return + def run(self): while True: self.clear_screen() self.print_header() - print(f"1) {self('o_Send_Message')}") - print(f"2) {self('o_Take_Photo')}") - print(f"3) {self('o_Attach_File')}") - print(f"4) {self('o_Chats')}") - print(f"5) {self('o_Settings')}") - print(f"6) {self('o_Go_Back')}") + + if self.handle_pending_apply_requests(): + continue + + if self.handle_pending_web_search_requests(): + continue + + print(f"1) {self('o_History')}") + print(f"2) {self('o_Send_Message')}") + print(f"3) {self('o_Microphone')}") + print(f"4) {self('o_Take_Photo')}") + print(f"5) {self('o_Attach_File')}") + print(f"6) {self('o_Chats')}") + print(f"7) {self('o_Settings')}") + print(f"8) {self('o_Exit')}") choice = self._read_input(f"\n{self('o_Selection')}: ").strip() if choice.isdigit(): num = int(choice) + + if num >= 201: + if self.toggle_pending_file_edit_by_number(num): + continue + if num >= 101: if self.delete_pending_attachment_by_number(num): continue if choice == "1": - self.send_message_flow() + self.print_chat_history() elif choice == "2": + self.send_message_flow() + + elif choice == "3": + self.voice_input_flow() + + elif choice == "4": ok = self.take_photo_and_queue() if ok: self.send_message_flow() - elif choice == "3": + elif choice == "5": ok = self.add_file_prompt() if ok: self.send_message_flow() - elif choice == "4": + elif choice == "6": self.menu_chat() - elif choice == "5": + elif choice == "7": self.menu_settings() - elif choice == "6": + elif choice == "8": self.clear_screen() print("🏁") return