Security and robustness (backend): - Strict CSP (was null); confine read_file/launch_file/ue_log to the client root - p4 trust without -f: surface fingerprint CHANGES instead of auto-accepting (MITM) - stdin=null by default + non-interactive P4EDITOR so terminal commands never hang - 64 MB read caps on read_depot/read_file/print_depot (no OOM on huge .uasset/.pak) - Recover from mutex poisoning instead of crashing every later p4 call - Decode p4 output as UTF-8 then cp1251 (Cyrillic paths no longer corrupt) - p4_changes never deletes changelists that hold shelved files - Validate changelist number in p4_undo_change Bug fixes (frontend): - Focus-refresh preserves the checkbox selection (never re-checks deselected files) - Staleness guards on refresh/describe/AI-summary/user-groups (no cross-selection races) - Real F5/Ctrl+R/G/S/B handlers; match e.code so Ctrl+backquote works on RU layout - Confirm dialog no longer fires on a stray global Enter - Panel/dock/history drags end on window blur (no stuck resize) - Commit draft clears on Submit only if unchanged since committing - Summary/IDE buttons gated to real source files (not .pak/.dll/binaries) New features: - Exclusive lock/unlock plus held-by/locked-by indicators (p4 opened -a) - Shelve/unshelve/delete-shelf on pending changelists - Resolve UI (auto-merge / accept yours / accept theirs, per file or all) + banner - Diff a history revision against the previous one; blame/annotate view - Depot file search (Tools -> Search depot) - Move opened files between pending changelists - New-submit toasts from teammates + offline banner with auto-reconnect Also: bake OpenRouter key at build time (git-ignored), drop AI settings UI, IDE picker (detect installed editors), AI replies in the UI language, persistent code summaries, metadata/CSP polish. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
118 lines
5.4 KiB
TypeScript
118 lines
5.4 KiB
TypeScript
// 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<string, string> = { 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<string> {
|
||
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<string> {
|
||
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";
|
||
}
|