console: React 生产级重写(左列表 + 中对话 + 右详情)

旧单页 console.html 换成 Vite + React + TypeScript 单文件构建产物
(309525 字节,sha 90345ec8…,重建字节一致),源码在
cloud/console-react/,改页面只改 src 再 npm run build:console,
产物不手改。布局:左侧列表(模块导航 + 任务列表)、中间标准对话
(实时链路时间线 + 继续会话聊天框,共用 Chat 组件)、右侧详情
(元数据、操作、评审、审批、协调子任务、子代理、线程)。
API 与后端语义未改,key 只存浏览器 localStorage。
6 个 console 标记单测随实现改名更新(意图不变:壳确实调了
这些接口、turn 信息仍在详情里、旧 goal 输入仍不存在);
P1 标记单测未动且通过。
This commit is contained in:
host committed 2026-10-29 06:32:38 +08:00
1 parent 3f4e849a93
commit ebc5624893
57 files changed
+4789 -1746

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
// 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("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 || "");
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;
}