Files
exbyte-depot-viewer-perforce/src/ai.ts
Bonchellon 8e2cac4437 Release 0.2.2 — browse submitted changelists, People & Roles, AI summaries
- History: GitHub-style split view of submitted changelists; preview file
  contents at their exact revision (code/image/3D/uasset) via `p4 print`
- People & Roles modal: who works on the depot, activity indicators,
  group/role assignment (p4 users / groups / group -i)
- AI commit summaries via OpenRouter (sparkle button); key/model in Settings
- Commit-message draft persists until Submit (not cleared on local commit)
- Resizable History file-list column (pointer-capture drag)
- Fix UI-scale leaving a gap at the bottom (compensate zoom in .win height)
- Author avatars on history rows; bump version to 0.2.2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:18:07 +03:00

75 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// OpenRouter-powered commit summary + small avatar helpers.
import { p4, OpenedFile, statusOf } from "./p4";
const MODEL_KEY = "exd-ai-model";
const KEY_KEY = "exd-ai-key";
// cheap + reliable default; change to a "…:free" model in Settings to pay nothing
export const DEFAULT_MODEL = "openai/gpt-4o-mini";
export function getModel(): string { try { return localStorage.getItem(MODEL_KEY) || DEFAULT_MODEL; } catch { return DEFAULT_MODEL; } }
export function setModel(m: string) { try { localStorage.setItem(MODEL_KEY, m.trim() || DEFAULT_MODEL); } catch {} }
export function getKeyLocal(): string { try { return localStorage.getItem(KEY_KEY) || ""; } catch { return ""; } }
export function setKeyLocal(k: string) { try { localStorage.setItem(KEY_KEY, k.trim()); } catch {} }
// key resolution: localStorage first, else the app-config file (git-ignored)
export async function resolveKey(): Promise<string> {
const local = getKeyLocal();
if (local) return local;
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 — set it in Settings");
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: getModel(),
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." },
{ 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 };
}
// --- 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";
}