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>
This commit is contained in:
74
src/ai.ts
Normal file
74
src/ai.ts
Normal file
@ -0,0 +1,74 @@
|
||||
// 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";
|
||||
}
|
||||
Reference in New Issue
Block a user