// OpenRouter-powered commit summary + small avatar helpers. import { p4, OpenedFile, statusOf } from "./p4"; import { LANG } from "./i18n"; // AI replies follow the app's UI language const LANG_NAMES: Record = { en: "English", ru: "Russian", de: "German", fr: "French", es: "Spanish" }; function langName(): string { return LANG_NAMES[LANG] || "English"; } // fixed model — no user-facing AI settings export const DEFAULT_MODEL = "openai/gpt-4o-mini"; // the key is baked into the binary at build time (src-tauri/openrouter.key) export async function resolveKey(): Promise { try { return (await p4.readAiKey()) || ""; } catch { return ""; } } // generate a commit summary + description from the changed files export async function aiSummary(files: OpenedFile[]): Promise<{ summary: string; description: string }> { const key = await resolveKey(); if (!key) throw new Error("No OpenRouter API key baked into this build"); const list = files.slice(0, 200).map((f) => `${statusOf(f.action).ch} ${f.depotFile || ""}`).join("\n"); const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", "X-Title": "Exbyte Depot" }, body: JSON.stringify({ model: DEFAULT_MODEL, max_tokens: 300, temperature: 0.3, messages: [ { role: "system", content: `You write concise commit messages for a Perforce/Unreal project. Given a list of changed files (+ added, ± edited, − deleted), reply with ONE short imperative summary line (under 72 chars, no trailing period), then a blank line, then an optional 1-3 line description. Plain text only — no markdown, no code fences, no quotes. Write the entire reply in ${langName()}.` }, { role: "user", content: `Changed files:\n${list}` }, ], }), }); if (!res.ok) { const txt = await res.text().catch(() => ""); throw new Error(`OpenRouter ${res.status}: ${txt.slice(0, 180)}`); } const data = await res.json(); const content: string = (data?.choices?.[0]?.message?.content || "").trim(); if (!content) throw new Error("Empty AI response"); const lines = content.split("\n"); const summary = (lines[0] || "").replace(/^["'`*]+|["'`*]+$/g, "").trim(); const description = lines.slice(1).join("\n").trim(); return { summary, description }; } // briefly explain what a source file does (2–4 sentences, plain text) export async function aiExplainCode(name: string, code: string): Promise { const key = await resolveKey(); if (!key) throw new Error("No OpenRouter API key baked into this build"); const snippet = code.slice(0, 14000); // cap the payload const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", "X-Title": "Exbyte Depot" }, body: JSON.stringify({ model: DEFAULT_MODEL, max_tokens: 260, temperature: 0.2, messages: [ { role: "system", content: `You explain source files for a Perforce/Unreal C++ (and general game) project. Given one file, reply with a SHORT plain-text summary (2–4 sentences) of what it does and its role in the project. No markdown, no code fences, no headings, no bullet points — a single tight paragraph. Write the entire reply in ${langName()}.` }, { role: "user", content: `File: ${name}\n\n${snippet}` }, ], }), }); if (!res.ok) { const txt = await res.text().catch(() => ""); throw new Error(`OpenRouter ${res.status}: ${txt.slice(0, 180)}`); } const data = await res.json(); const content: string = (data?.choices?.[0]?.message?.content || "").trim(); if (!content) throw new Error("Empty AI response"); return content; } // --- persistent cache of AI code summaries (removed only via the × button) --- const EXPL_KEY = "exd-explain"; type ExplEntry = { k: string; v: string }; function loadExpl(): ExplEntry[] { try { const r = localStorage.getItem(EXPL_KEY); const l = r ? JSON.parse(r) : []; return Array.isArray(l) ? l : []; } catch { return []; } } function storeExpl(list: ExplEntry[]) { try { localStorage.setItem(EXPL_KEY, JSON.stringify(list)); } catch {} } export function getExplain(key: string): string | null { return loadExpl().find((e) => e.k === key)?.v ?? null; } export function saveExplain(key: string, text: string) { const list = loadExpl().filter((e) => e.k !== key); list.push({ k: key, v: text }); storeExpl(list.slice(-80)); // keep the 80 most recent } export function dropExplain(key: string) { storeExpl(loadExpl().filter((e) => e.k !== key)); } // --- avatar helpers --- export function initials(name: string, user: string): string { const n = (name || "").trim(); if (n) { const p = n.split(/\s+/); return (p[0][0] + (p[1]?.[0] || "")).toUpperCase(); } return (user || "?").slice(0, 2).toUpperCase(); } export function avatarColor(seed: string): string { let h = 0; for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0; return `hsl(${h % 360} 52% 52%)`; } // activity level from a unix-epoch last-access string export function activity(access?: string): "active" | "recent" | "idle" { const n = Number(access || 0) * 1000; if (!n) return "idle"; const ago = Date.now() - n; if (ago < 60 * 60 * 1000) return "active"; // < 1h if (ago < 24 * 60 * 60 * 1000) return "recent"; // < 1d return "idle"; }