左侧栏 ProjectNav:项目完整列表(名 + 短 id + 我的角色, 选中高亮,点击切换)+ 创建项目(名/GitHub 仓库/repo_url) + 选中项目成员(邀请/改角色/新增移除);工作台删除 sec-projects 一节与任务列表里的项目下拉框,侧栏为唯一 项目选择器,自动选第一个项目逻辑不变。 薄模块补齐(均为已核实后端路由):成员移除 DELETE、 自动化详情(GET 单条 + 产出)与编辑 PATCH、分诊条目点进 任务/自动化详情、Workers 搜索与在线筛选、管理区设置配额 POST。主线新功能(Todos、Browser、taskMeta 等)原样保留, 未做任何回退。console 标记单测本地 53 项通过。
131 lines
6.8 KiB
TypeScript
131 lines
6.8 KiB
TypeScript
// 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, ">").replace(/"/g, """);
|
||
}
|
||
|
||
export async function api<T>(path: string, method = "GET", body?: unknown): Promise<T> {
|
||
const headers: Record<string, string> = { "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<string> {
|
||
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<string, boolean> = {};
|
||
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;
|
||
}
|