// API client: a faithful port of the live console's api(). // Same-origin absolute paths via paths.ts, X-API-Key from localStorage // only, never hardcoded, never sent anywhere else. Error text matches // the live file, including the 401 / 429 / 402 distinctions. import { paths } from "./paths"; import { TRACE_KIND } from "./types"; import type { TraceEntry } from "./types"; const KEY_STORE = "codex-console-key"; export function getKey(): string { try { return localStorage.getItem(KEY_STORE) || ""; } catch { return ""; } } export function saveKey(v: string): void { localStorage.setItem(KEY_STORE, v); } export function clearKey(): void { localStorage.removeItem(KEY_STORE); } export function shortId(id?: string | null): string { return id ? id.slice(0, 8) : ""; } export function fmt(ts?: number | null): string { if (!ts) return ""; return new Date(ts * 1000).toLocaleString(); } export function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } export async function api(path: string, method = "GET", body?: unknown): Promise { const headers: Record = { "Content-Type": "application/json" }; const key = getKey(); if (key) headers["X-API-Key"] = key; const opt: RequestInit = { method, headers }; if (body !== undefined) opt.body = JSON.stringify(body); const r = await fetch(path, opt); let data: unknown = null; try { data = await r.json(); } catch { data = null; } if (!r.ok) { const d = data as { error?: string; unpriced_token_cap?: unknown } | null; let msg = d && d.error ? d.error : "HTTP " + r.status; if (r.status === 401) msg += "(key 未填或无效)"; if (r.status === 429) msg += "(触发限流,稍后再试)"; if (r.status === 402) { if (d && d.unpriced_token_cap !== undefined) msg += "(无价 token 已满,联系管理员调整)"; else if (d && d.error === "coordinator_quota_reserve_exceeded") msg = "协调员分工预留不足:剩余美元配额不够覆盖全部分工,子任务费用记父任务创建人账,整个父任务已被挡下(未建任何子任务)"; else msg += "(美元配额已满,联系管理员调整)"; } throw new Error(msg); } return data as T; } // Uploads cannot reuse api(): multipart boundary must be browser-generated. export async function uploadAttachments(items: Array<{ path: string; file: File }>): Promise { const fd = new FormData(); fd.append("paths", JSON.stringify(items.map((x) => x.path))); items.forEach((x, i) => fd.append("file" + i, x.file)); const r = await fetch(paths.uploads, { method: "POST", headers: { "X-API-Key": getKey() }, body: fd }); const d = await r.json().catch(() => ({} as { error?: string; upload_id?: string })); if (!r.ok) throw new Error((d && d.error) || "HTTP " + r.status); return d.upload_id as string; } export function gatherAttachments(files: FileList | null, dir: FileList | null, imgs: FileList | null): Array<{ path: string; file: File }> { const out: Array<{ path: string; file: File }> = []; const seen: Record = {}; const add = (list: FileList | null, pathOf: (f: File) => string) => { if (!list) return; for (let i = 0; i < list.length; i++) { const p = pathOf(list[i]); if (!p || seen[p]) continue; seen[p] = true; out.push({ path: p, file: list[i] }); } }; add(files, (f) => f.name); add(dir, (f) => (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name); add(imgs, (f) => f.name); return out; } export function workerAgeText(w: { last_seen?: number; last_seen_seconds_ago?: number }): { text: string; age: number | null } { const now = Date.now() / 1000; let age: number | null = null; if (typeof w.last_seen === "number") age = now - w.last_seen; else if (typeof w.last_seen_seconds_ago === "number") age = w.last_seen_seconds_ago; if (age === null || age < 0) age = null; if (age === null) return { text: "未知", age: null }; const s = Math.floor(age); if (s < 60) return { text: s + " 秒前", age }; if (s < 3600) return { text: Math.floor(s / 60) + " 分钟前", age }; return { text: Math.floor(s / 3600) + " 小时前", age }; } export function memberErr(e: Error): string { const m = e.message || ""; if (m.includes("forbidden")) return "没有权限:只有项目 owner 或全局管理员可以管理成员"; if (m.includes("already_member")) return "该用户已是成员,改角色请用成员行的下拉框"; if (m.includes("cannot demote the only owner")) return "不能把唯一 owner 降为非 owner:请先把另一位成员升为 owner"; if (m.includes("cannot remove the only owner")) return "不能移除唯一 owner:请先把另一位成员升为 owner"; if (m.includes("user not found")) return "找不到该用户:请核对 user_id 或用户名是否正确"; if (m.includes("member not found")) return "该用户不是本项目成员"; if (m.includes("unknown role")) return "角色无效:只能是 viewer / contributor / maintainer / owner"; if (m.includes("project not found")) return "项目不存在(可能已被删除)"; return "成员操作失败:" + m; } export function adminErr(e: Error): string { const m = e.message || ""; if (m.includes("forbidden")) return "无全局管理员权限:服务端返回 403(CODEX_GLOBAL_ADMINS 未包含当前用户),请联系管理员"; if (m.includes("user not found")) return "找不到该用户:请核对用户 id 是否正确"; return "管理操作失败:" + m; } export function fmtUsd(micros?: number | null): string { if (micros === null || micros === undefined) return "不限额"; return "$" + (micros / 1000000).toFixed(2); } export function traceLineText(e: TraceEntry): string { const k = TRACE_KIND[e.kind || ""] || e.kind || "?"; let body = ""; if (e.kind === "exec") body = (e.command || "") + "(exit=" + (e.exitCode ?? "") + "," + (e.durationMs ?? "") + "ms,cwd=" + (e.cwd || "") + ")"; else if (e.kind === "file") body = (e.path || "") + " " + (e.change || "") + " " + (e.status || ""); else if (e.kind === "message") body = (e.phase ? "[" + e.phase + "] " : "") + (e.text || "") + (e.truncated ? "(链路摘要已截断至 300 字,完整回复见右侧任务结果)" : ""); else if (e.kind === "reasoning") body = e.summary || ""; else if (e.kind === "tool") body = (e.server || "") + "/" + (e.tool || "") + " " + (e.status || ""); else if (e.kind === "call") body = e.name || ""; else if (e.kind === "plan") body = e.text || ""; else if (e.kind === "agent-start") body = "开始:" + (e.name || "") + (e.prompt ? "(" + e.prompt + ")" : ""); else if (e.kind === "agent-done") body = "结束:" + (e.name || "") + " " + (e.status || ""); else body = JSON.stringify(e).slice(0, 300); return "[" + k + "] " + body; }