Files
codex-app-server/cloud/console-react/src/api/client.ts
T
host 08484302ac console: 项目导航移到左侧栏,工作台不再承担项目模块
左侧栏 ProjectNav:项目完整列表(名 + 短 id + 我的角色,
选中高亮,点击切换)+ 创建项目(名/GitHub 仓库/repo_url)
+ 选中项目成员(邀请/改角色/新增移除);工作台删除
sec-projects 一节与任务列表里的项目下拉框,侧栏为唯一
项目选择器,自动选第一个项目逻辑不变。
薄模块补齐(均为已核实后端路由):成员移除 DELETE、
自动化详情(GET 单条 + 产出)与编辑 PATCH、分诊条目点进
任务/自动化详情、Workers 搜索与在线筛选、管理区设置配额
POST。主线新功能(Todos、Browser、taskMeta 等)原样保留,
未做任何回退。console 标记单测本地 53 项通过。
2026-10-30 07:43:54 +00:00

131 lines
6.8 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.
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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;
}