#!/usr/bin/env python3 import os import sys import json import base64 import mimetypes import shutil import argparse from pathlib import Path from datetime import datetime, timezone from typing import NoReturn, Any, Dict import requests CONFIG_PATH = Path.home() / ".config" / "capture-ai" / "config.json" CACHE_BASE = Path.home() / ".cache" / "capture-ai" REFS_CACHE_DIR = CACHE_BASE / "refs" OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" GLOBAL_SYSTEM_PROMPT = ( "Bu mesaj bir talimat setidir." "Bu mesaja cevap verme." "Sadece kullanıcı mesajlarına cevap ver.\n" "Bu konuşmada sana zaman zaman e-posta, kod, komut, yapılandırma dosyası " "veya birebir kopyalanması gereken metinler yazdırılabilir.\n\n" "FORMAT KURALI:\n" "Eğer kullanıcıdan gelen istekte doğrudan kopyalanabilir bir içerik " "(örneğin kod, script, terminal komutu, yapılandırma dosyası vb.) " "üretmen gerekiyorsa aşağıdaki formatı kullan:\n\n" "title\n" "\n" "title\n" "copy\n" "\n" "copy\n\n" "Kurallar:\n" "- 'title' ve 'copy' kelimeleri tek başına satır olmalıdır.\n" "- 'title' bloğu kod bloğundan önce gelmelidir.\n" "- Kod yoksa bu formatı kullanma.\n" "- Normal sohbet cevaplarında bu formatı kullanma.\n" "- Sadece gerçekten kopyalanabilir içerik varsa bu formatı kullan.\n" "\n\n[FILE] BLOKLARI HAKKINDA:\n" "- Eğer kullanıcı bir dosya eklediyse, dosya içeriği bu mesaj içinde " "[FILE] ... [CONTENT] ... [/CONTENT] formatında ZATEN verilmiştir.\n" "- Bu durumda kullanıcıdan 'kodu buraya yapıştır' veya 'dosyayı paylaş' deme.\n" "- Değişiklik gerekiyorsa [CONTENT] içeriğini okuyarak işlem yap.\n" "- Dosya içeriğini tekrar isteme.\n" "- path alanı olarak [FILE] bloğunda verilen path değerini aynen kullan.\n" "\n\nDOSYA DEĞİŞİKLİĞİ KURALI:\n" "- [FILE] bloklarında 'editable: true' ise dosyada değişiklik önerebilirsin.\n" "- 'editable: false' olan dosyalar için ASLA dosyaya uygulanacak değişiklik isteme.\n" "- editable:false dosyalar için apply bloğu üretme.\n" "- Eğer değişiklik gerekmiyorsa apply bloğu hiç koyma.\n" "\n\nAPPLY (OTOMATİK UYGULAMA) PROTOKOLÜ:\n" "Eğer ve sadece eğer kullanıcı 'editable: true' bir dosyada değişiklik istiyorsa,\n" "cevabının SONUNA aşağıdaki blok formatıyla ekle (başka yerde kullanma):\n\n" "apply\n" "[\n" " {\n" " \"path\": \"/absolute/path/to/file\",\n" " \"find\": \"DOSYADA BİREBİR GEÇEN ANCHOR METİN\",\n" " \"mode\": \"before|after|replace\",\n" " \"text\": \"EKLENECEK/YERİNE GEÇECEK METİN\"\n" " }\n" "]\n" "apply\n\n" "Kurallar:\n" "- 'apply' kelimesi tek başına satır olmalı (başlangıç ve bitiş).\n" "- apply bloğunun içi SADECE JSON olmalı (başka açıklama koyma).\n" "- JSON sadece object/array olmalı, yorum (//) veya trailing virgül olamaz.\n" "- mode sadece: before, after, replace.\n" "- path MUTLAKA absolute path olmalı.\n" "- find, dosyada birebir bulunan kısa/orta bir anchor olmalı.\n" "- text, eklenecek içerik ve sonunda '\\n' gerekiyorsa ekle.\n" "\n\nAPPLY ÖZET SATIRI KURALI:\n" "- Eğer apply bloğu üretiyorsan, 'Başarıyla yaptım' gibi kesin/bitmiş ifade ASLA kullanma.\n" "- apply bloğundan ÖNCE sadece 1 satır özet yaz.\n" "- Özet formatı TAM olarak şu olsun:\n" " 'Bu satırı ediyorum: -> '\n" " MODE: replace|before|after\n" "- ve çok uzunsa 120 karakterde kes ve '...' ekle.\n" "- Özet dışında ekstra açıklama yazma.\n" "- Buraya kadar olan mesajlar talimat setidir, cevap verme. Sadece en son kullanıcı mesajını yanıtla." ) def _safe_read_text(path: Path, max_bytes: int = 250_000) -> str: try: data = path.read_bytes() if len(data) > max_bytes: data = data[:max_bytes] return data.decode("utf-8", errors="replace") except Exception as e: return f"[Dosya okunamadı: {e}]" def _is_text_file(path: Path) -> bool: ext = path.suffix.lower() return ext in { ".txt", ".md", ".py", ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".sh", ".bash", ".zsh", ".js", ".ts", ".tsx", ".jsx", ".css", ".html", ".xml", ".c", ".cpp", ".h", ".hpp", ".rs", ".go", ".java", ".kt", ".cs", ".sql" } def _die(msg: str, code: int = 1) -> NoReturn: sys.stderr.write(msg + "\n") raise SystemExit(code) def _load_config() -> Dict[str, Any]: if not CONFIG_PATH.exists(): _die("Config dosyası bulunamadı.", 1) try: with open(CONFIG_PATH, "r", encoding="utf-8") as f: return json.load(f) or {} except Exception as e: _die(f"Config okunamadı: {e}", 1) def _save_config(cfg: dict): try: CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) CONFIG_PATH.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8") except Exception: # config yazılamasa bile AI çalışsın pass def _parse_selected_indexes(arg: str) -> list[int]: out = [] if not arg: return out for x in arg.split(","): x = x.strip() if x.isdigit(): out.append(int(x)) # uniq + stable seen = set() uniq = [] for i in out: if i not in seen: seen.add(i) uniq.append(i) return uniq def _mime_for(path: Path) -> str: mime, _ = mimetypes.guess_type(str(path)) return mime or "application/octet-stream" def _data_url_for_image(path: Path) -> str: mime = _mime_for(path) b64 = base64.b64encode(path.read_bytes()).decode("ascii") return f"data:{mime};base64,{b64}" def _role_map(chat_role: str) -> str: return "assistant" if chat_role == "bot" else "user" def _safe_chat_key(chat_file: Path) -> str: return chat_file.stem.replace("/", "_").replace("\\", "_") def _utc_stamp() -> str: return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") def _copy_to_cache(src: Path, dest_dir: Path) -> Path: dest_dir.mkdir(parents=True, exist_ok=True) base = src.stem.replace(" ", "_") ext = src.suffix or ".img" dest = dest_dir / f"{base}{ext}" if dest.exists(): dest = dest_dir / f"{base}_{_utc_stamp()}{ext}" shutil.copy2(src, dest) return dest def _build_blocks_and_cache_info(msg: dict, cache_images_dir: Path | None): blocks = [] cached_image = None # 1) Text txt = msg.get("content") or "" if txt: blocks.append({"type": "text", "text": txt}) # 2) Images (legacy "image" + new "images") imgs = [] one = msg.get("image") if one: imgs.append(one) many = msg.get("images") if isinstance(many, list): imgs.extend([str(x) for x in many if x]) # uniq seen = set() uniq_imgs = [] for p in imgs: if p not in seen: seen.add(p) uniq_imgs.append(p) for img_raw in uniq_imgs: img_path = Path(img_raw) if not img_path.exists(): continue if cache_images_dir is not None: try: cached_image = _copy_to_cache(img_path, cache_images_dir) except Exception: cached_image = cached_image # önceki varsa kalsın try: blocks.append({ "type": "image_url", "image_url": {"url": _data_url_for_image(img_path)} }) except Exception: pass # 3) Files (new: "files": [{"path","name","edit"}]) files = msg.get("files") or [] if isinstance(files, list): for f in files: if not isinstance(f, dict): continue p_raw = str(f.get("path") or "").strip() if not p_raw: continue p = Path(p_raw) if not p.exists() or not p.is_file(): continue editable = bool(f.get("edit")) name = (f.get("name") or p.name or "").strip() mime = _mime_for(p) if _is_text_file(p): file_text = _safe_read_text(p) blocks.append({ "type": "text", "text": ( "\n[FILE]\n" f"name: {name}\n" f"path: {str(p)}\n" f"mime: {mime}\n" f"editable: {'true' if editable else 'false'}\n" "type: text\n" "[CONTENT]\n" f"{file_text}\n" "[/CONTENT]\n" "[/FILE]\n" ) }) else: # Excel/PDF/DOCX vb. şu an içerik aktarmıyoruz (metadata gönderiyoruz) blocks.append({ "type": "text", "text": ( "\n[FILE]\n" f"name: {name}\n" f"path: {str(p)}\n" f"mime: {mime}\n" f"editable: {'true' if editable else 'false'}\n" "type: binary_or_unsupported_preview\n" "note: Bu dosya türü şu an içerik olarak gönderilmiyor.\n" "[/FILE]\n" ) }) if not blocks: blocks = [{"type": "text", "text": ""}] return blocks, (str(cached_image) if cached_image else None) def fix_mojibake(s: str) -> str: if not s: return s # Mojibake tipik işaretleri if ("Ã" not in s) and ("Å" not in s) and ("â" not in s): return s try: # yanlışlıkla latin-1/cp1252 gibi decode edilmiş UTF-8'i düzelt return s.encode("latin-1").decode("utf-8") except Exception: return s def _message_to_plain_text(content) -> str: if isinstance(content, str): return content.strip() if isinstance(content, list): parts = [] for item in content: if not isinstance(item, dict): continue if item.get("type") == "text": txt = item.get("text") if isinstance(txt, str) and txt.strip(): parts.append(txt.strip()) return "\n".join(parts).strip() return "" def _extract_images_from_message(msg: dict) -> dict: """ OpenRouter / farklı provider cevaplarından image url veya base64 çıkar. UI'nin finalize_ai_response() fonksiyonunun anlayacağı formatı döndürür. """ out = { "type": "text", "content": "", } if not isinstance(msg, dict): return out content = msg.get("content") text_parts = [] image_urls = [] image_b64 = [] # 1) content string ise if isinstance(content, str): if content.strip(): text_parts.append(content.strip()) # 2) content liste ise elif isinstance(content, list): for item in content: if not isinstance(item, dict): continue itype = str(item.get("type") or "").strip().lower() if itype == "text": txt = item.get("text") if isinstance(txt, str) and txt.strip(): text_parts.append(txt.strip()) elif itype in ("image_url", "output_image"): iu = item.get("image_url") if isinstance(iu, str) and iu.strip(): image_urls.append(iu.strip()) elif isinstance(iu, dict): u = iu.get("url") if isinstance(u, str) and u.strip(): image_urls.append(u.strip()) b64 = item.get("b64_json") if isinstance(b64, str) and b64.strip(): image_b64.append(b64.strip()) data = item.get("image") if isinstance(data, str) and data.strip(): image_b64.append(data.strip()) # 3) message level fallback alanlar for key in ("image", "url"): val = msg.get(key) if isinstance(val, str) and val.strip(): image_urls.append(val.strip()) one_b64 = msg.get("image_base64") if isinstance(one_b64, str) and one_b64.strip(): image_b64.append(one_b64.strip()) imgs = msg.get("images") if isinstance(imgs, list): for item in imgs: if isinstance(item, str) and item.strip(): image_urls.append(item.strip()) elif isinstance(item, dict): u = item.get("url") if isinstance(u, str) and u.strip(): image_urls.append(u.strip()) iu = item.get("image_url") if isinstance(iu, str) and iu.strip(): image_urls.append(iu.strip()) elif isinstance(iu, dict): uu = iu.get("url") if isinstance(uu, str) and uu.strip(): image_urls.append(uu.strip()) b64 = item.get("b64_json") if isinstance(b64, str) and b64.strip(): image_b64.append(b64.strip()) # uniq def _uniq(seq): seen = set() out2 = [] for x in seq: if x not in seen: seen.add(x) out2.append(x) return out2 image_urls = _uniq(image_urls) image_b64 = _uniq(image_b64) out["content"] = "\n".join(text_parts).strip() if image_urls: out["type"] = "image" out["images"] = image_urls if image_b64: out["type"] = "image" out["images_base64"] = image_b64 if image_urls and image_b64: out["type"] = "image" return out def _is_image_generation_model(model_name: str) -> bool: m = str(model_name or "").strip().lower() return any(x in m for x in [ "image", "gpt-image", "imagen", "flux", "recraft", "stable-diffusion" ]) def _pick_model(cfg: dict, chat_file: Path, cli_model: str | None) -> str: """ Öncelik: 1) CLI --model verilmişse onu kullan (alternatif) 2) chat_models[chat_file.name] 3) ai_models[0] (en üst = en son kullanılan) Eğer chat'in modeli config'te yoksa/silinmişse otomatik ai_models[0]'a düzeltir. """ models = cfg.get("ai_models", []) if not isinstance(models, list): models = [] models = [str(m).strip() for m in models if str(m).strip()] if not models: _die("Config içinde ai_models boş. En az 1 model olmalı.", 1) if cli_model: return str(cli_model).strip() chat_models = cfg.get("chat_models", {}) if not isinstance(chat_models, dict): chat_models = {} chat_key = chat_file.name chosen = str(chat_models.get(chat_key) or "").strip() if (not chosen) or (chosen not in models): chosen = models[0] chat_models[chat_key] = chosen cfg["chat_models"] = chat_models _save_config(cfg) return chosen def main(): try: if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") if hasattr(sys.stderr, "reconfigure"): sys.stderr.reconfigure(encoding="utf-8", errors="replace") except Exception: pass # Ekstra sağlamlaştırma (bazı ortamlarda fark yaratır) os.environ["PYTHONIOENCODING"] = "utf-8" # Args parser = argparse.ArgumentParser(add_help=False) parser.add_argument("chat_file", nargs="?") parser.add_argument("selected_indexes", nargs="?") parser.add_argument("--model", dest="model", default=None) if len(sys.argv) < 2: sys.exit(1) args, _unknown = parser.parse_known_args() if not args.chat_file: sys.exit(1) chat_file = Path(args.chat_file) if not chat_file.exists(): sys.exit(1) selected_indexes = [] if args.selected_indexes: try: selected_indexes = _parse_selected_indexes(args.selected_indexes) except Exception: selected_indexes = [] # Config + API key cfg = _load_config() response_style = (cfg.get("response_style") or "").strip() API_KEY = (cfg.get("open_router_key") or "").strip() if not API_KEY: _die("OpenRouter API Key bulunamadı.", 1) # model seçimi (chat’e göre) model_name = _pick_model(cfg, chat_file, args.model) # Read chat messages = [] try: messages = json.loads(chat_file.read_text(encoding="utf-8")) or [] except Exception as e: _die(f"Chat okunamadı: {e}", 1) if not messages: sys.exit(0) if messages and isinstance(messages[-1], dict) and messages[-1].get("streaming") and messages[-1].get("role") in ("bot", "assistant"): messages = messages[:-1] # placeholder'ı payload'a sokma if not messages: sys.exit(0) # --- son gerçek user mesajını bul --- last_user = None for m in reversed(messages): if isinstance(m, dict) and m.get("role") == "user": last_user = m break if last_user is None: _die("Chat içinde user mesajı yok.", 1) last = last_user final_messages = [] editable_map = {} # path(str) -> bool final_messages.append({ "role": "system", "content": GLOBAL_SYSTEM_PROMPT }) if response_style: final_messages.append({ "role": "system", "content": f"Kullanıcı şu konuşma tarzını tercih ediyor: {response_style}" }) # Cache setup REFS_CACHE_DIR.mkdir(parents=True, exist_ok=True) # Reference system (TEXT + IMAGE) + CACHE if selected_indexes: chat_key = _safe_chat_key(chat_file) bundle_id = _utc_stamp() bundle_dir = REFS_CACHE_DIR / chat_key / bundle_id bundle_images_dir = bundle_dir / "images" bundle_dir.mkdir(parents=True, exist_ok=True) final_messages.append({ "role": "system", "content": ( "Aşağıdaki mesajlar sadece son kullanıcı mesajını anlaman için referans olarak verilmiştir. " "Bu mesajlara cevap verme. Sadece en son kullanıcı mesajını yanıtla." ) }) cache_items = [] for i in selected_indexes: if i < len(messages) - 1: msg = messages[i] role = _role_map(msg.get("role")) has_any_attach = False # editable_map doldur (referans mesajın file edit izinleri) files_meta = msg.get("files") or [] if isinstance(files_meta, list): for ff in files_meta: if not isinstance(ff, dict): continue p_raw = str(ff.get("path") or "").strip() if not p_raw: continue editable_map[p_raw] = bool(ff.get("edit")) # images? if msg.get("image") and Path(str(msg["image"])).exists(): has_any_attach = True imgs = msg.get("images") if isinstance(imgs, list) and any(Path(str(p)).exists() for p in imgs): has_any_attach = True # files? files = msg.get("files") if isinstance(files, list) and any(Path(str(f.get("path") or "")).exists() for f in files if isinstance(f, dict)): has_any_attach = True if has_any_attach: blocks, cached_img = _build_blocks_and_cache_info(msg, bundle_images_dir) final_messages.append({"role": role, "content": blocks}) else: final_messages.append({"role": role, "content": msg.get("content", "")}) cached_img = None cache_items.append({ "index": i, "role": msg.get("role", ""), "content": msg.get("content", ""), "image_original": msg.get("image", None), "images_original": msg.get("images", None), "files_original": msg.get("files", None), "image_cached": cached_img }) bundle = { "bundle_id": bundle_id, "created_at_utc": datetime.now(timezone.utc).isoformat(), "chat_file": str(chat_file), "selected_indexes": selected_indexes, "items": cache_items } try: (bundle_dir / "bundle.json").write_text( json.dumps(bundle, ensure_ascii=False, indent=2), encoding="utf-8" ) except Exception: pass # Last message (answer) - TEXT + (image / images) # last message editable_map doldur files_last = last.get("files") or [] if isinstance(files_last, list): for ff in files_last: if not isinstance(ff, dict): continue p_raw = str(ff.get("path") or "").strip() if not p_raw: continue editable_map[p_raw] = bool(ff.get("edit")) # Last message (answer) - TEXT + (images/files) role = _role_map(last.get("role")) has_any_attach = False # images? if last.get("image") and Path(str(last["image"])).exists(): has_any_attach = True imgs = last.get("images") if isinstance(imgs, list) and any(Path(str(p)).exists() for p in imgs): has_any_attach = True # files? files = last.get("files") if isinstance(files, list) and any( Path(str(f.get("path") or "")).exists() for f in files if isinstance(f, dict) ): has_any_attach = True if has_any_attach: blocks, _ = _build_blocks_and_cache_info(last, cache_images_dir=None) final_messages.append({"role": role, "content": blocks}) else: final_messages.append({"role": role, "content": last.get("content", "")}) # Payload is_image_model = _is_image_generation_model(model_name) payload = { "model": model_name, "messages": final_messages, "max_tokens": 5120, "stream": False if is_image_model else True } # Request (STREAMING) reply = "" usage_obj = None try: response = requests.post( OPENROUTER_URL, headers={ "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json", "HTTP-Referer": "http://localhost", "X-Title": "capture-ai" }, json=payload, timeout=120, stream=not is_image_model ) if response.status_code != 200: sys.stderr.write(f"OpenRouter API ERROR ({response.status_code}): {response.text}\n") sys.exit(1) if is_image_model: data = response.json() usage_data = data.get("usage") if usage_data: prompt_t = int(usage_data.get("prompt_tokens", 0) or 0) comp_t = int(usage_data.get("completion_tokens", 0) or 0) total_t = int(usage_data.get("total_tokens", 0) or 0) usage_obj = { "prompt_tokens": prompt_t, "completion_tokens": comp_t, "total_tokens": total_t } msg = {} try: msg = data["choices"][0]["message"] except Exception: msg = {} result_obj = _extract_images_from_message(msg) # hiç image yoksa düz text fallback if not result_obj.get("content") and not result_obj.get("images") and not result_obj.get("images_base64"): txt = _message_to_plain_text(msg.get("content")) result_obj = { "type": "text", "content": fix_mojibake(txt) } if usage_obj is not None: result_obj["usage"] = usage_obj reply = json.dumps(result_obj, ensure_ascii=False) sys.stdout.write(reply) sys.stdout.flush() else: reply_parts = [] usage_data = None for raw_line in response.iter_lines(decode_unicode=False, chunk_size=1024): if not raw_line: continue line = raw_line.strip() if not line.startswith(b"data:"): continue data_str = line[len(b"data:"):].strip() if data_str == b"[DONE]": break try: evt = json.loads(data_str.decode("utf-8", errors="replace")) if "usage" in evt: usage_data = evt["usage"] except Exception: continue delta = None try: delta = evt["choices"][0]["delta"].get("content") except Exception: delta = None if delta: delta = fix_mojibake(delta) reply_parts.append(delta) sys.stdout.write(delta) sys.stdout.flush() reply = "".join(reply_parts).strip() if usage_data: prompt_t = int(usage_data.get("prompt_tokens", 0) or 0) comp_t = int(usage_data.get("completion_tokens", 0) or 0) total_t = int(usage_data.get("total_tokens", 0) or 0) usage_obj = { "prompt_tokens": prompt_t, "completion_tokens": comp_t, "total_tokens": total_t } reply = fix_mojibake(reply) if not reply: sys.stderr.write("OpenRouter boş stream döndürdü.\n") sys.exit(1) except requests.exceptions.Timeout: sys.stderr.write("İstek zaman aşımına uğradı (timeout).\n") sys.exit(1) except requests.exceptions.ConnectionError: sys.stderr.write("Bağlantı hatası. İnternet bağlantınızı kontrol edin.\n") sys.exit(1) except Exception as e: sys.stderr.write(f"AI ERROR: {str(e)}\n") sys.exit(1) sys.exit(0) if __name__ == "__main__": main()