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;
}
+52
View File
@@ -0,0 +1,52 @@
// Every API path in the app, in one place. No section or hook builds a
// URL string by hand; they all go through this table. Paths are
// same-origin absolute paths, exactly as in the live console.
const enc = encodeURIComponent;
export const paths = {
health: "/health",
relayModels: "/relay/models",
quotaMe: "/users/me/quota",
githubRepos: "/github/repos",
projects: "/projects",
project: (id: string) => "/projects/" + enc(id),
projectMembers: (pid: string) => "/projects/" + enc(pid) + "/members",
projectMember: (pid: string, uid: string) => "/projects/" + enc(pid) + "/members/" + enc(uid),
projectSkills: (pid: string) => "/projects/" + enc(pid) + "/skills",
projectApps: (pid: string) => "/projects/" + enc(pid) + "/apps",
projectMcp: (pid: string) => "/projects/" + enc(pid) + "/mcp-servers",
projectMcpReload: (pid: string) => "/projects/" + enc(pid) + "/mcp-servers/reload",
projectMcpOauth: (pid: string, name: string) => "/projects/" + enc(pid) + "/mcp-servers/" + enc(name) + "/oauth-login",
projectAutomations: (pid: string) => "/projects/" + enc(pid) + "/automations",
projectAutomationCancel: (pid: string, id: string) => "/projects/" + enc(pid) + "/automations/" + enc(id) + "/cancel",
projectTriage: (pid: string) => "/projects/" + enc(pid) + "/triage",
workers: "/workers",
environments: (pid: string) => "/environments?project_id=" + enc(pid),
environmentsCreate: "/environments",
environmentArchive: (id: string) => "/environments/" + enc(id) + "/archive",
tasks: (pid: string) => "/tasks?project_id=" + enc(pid),
tasksByParent: (pid: string, parent: string) => "/tasks?project_id=" + enc(pid) + "&parent_task_id=" + enc(parent),
tasksCreate: "/tasks",
task: (id: string) => "/tasks/" + enc(id),
taskAction: (id: string, kind: string) => "/tasks/" + enc(id) + "/" + kind,
taskFork: (id: string) => "/tasks/" + enc(id) + "/fork",
taskContinue: (id: string) => "/tasks/" + enc(id) + "/continue",
taskTrace: (id: string) => "/tasks/" + enc(id) + "/trace",
taskTraceStream: (id: string) => "/tasks/" + enc(id) + "/trace/stream",
taskAgents: (id: string) => "/tasks/" + enc(id) + "/agents",
taskReviews: (id: string) => "/tasks/" + enc(id) + "/reviews",
taskReviewFindings: (id: string, rid: string) => "/tasks/" + enc(id) + "/reviews/" + enc(rid) + "/findings",
taskApprovals: (id: string) => "/tasks/" + enc(id) + "/approvals",
taskApprovalDecision: (id: string, aid: string, verb: string) => "/tasks/" + enc(id) + "/approvals/" + enc(aid) + "/" + verb,
taskFollowups: (id: string) => "/tasks/" + enc(id) + "/followups",
taskMessages: (id: string) => "/tasks/" + enc(id) + "/messages",
taskMessagesTo: (target: string) => "/tasks/" + enc(target) + "/messages",
taskThread: (id: string) => "/tasks/" + enc(id) + "/thread",
taskThreads: (id: string) => "/tasks/" + enc(id) + "/threads",
taskThreadAction: (id: string, kind: string) => "/tasks/" + enc(id) + "/thread/" + kind,
taskCost: (id: string) => "/tasks/" + enc(id) + "/cost",
uploads: "/uploads",
adminQuotas: "/admin/quotas",
adminQuotaUser: (uid: string) => "/admin/quotas?user_id=" + enc(uid),
adminSweep: "/admin/sweep",
adminBackup: "/admin/backup",
};
+67
View File
@@ -0,0 +1,67 @@
// Types mirror the live console's JSON shapes. All fields optional except
// where the server always returns them, so a missing field renders as "" /
// "?" exactly like the original esc() fallbacks instead of crashing.
export interface Project { id: string; name: string; repo_url?: string; my_role?: string | null }
export interface Worker { worker_id?: string; id?: string; executor?: string; running?: number | null; capacity?: number | null; alive?: boolean | null; last_seen?: number; last_seen_seconds_ago?: number }
export interface Env { id: string; name: string; branch?: string; executor?: string; status?: string; error_text?: string }
export interface Task {
id: string; project_id?: string; prompt?: string; status: string; executor?: string;
worktree_host?: string; pr_url?: string; pr_number?: number | string; updated_at?: number;
turn_id?: string; root_turn_id?: string; plan?: number | boolean; forked_from?: string;
coordinator_parts?: Array<{ name: string; prompt?: string }> | null; parent_task_id?: string;
coordinator_part?: string; goal_mode?: number | boolean; verify_command?: string;
environment_id?: string; attachments?: string[]; model?: string; effort?: string;
child_model?: string; child_effort?: string; approval?: string;
result_text?: string; error_text?: string;
}
export interface Review { id: string; target_type?: string; branch?: string; sha?: string; instructions?: string; status?: string; created_at?: number; findings?: string }
export interface Approval { id: string; kind?: string; status?: string; summary?: string; decision?: string; decided_by?: string; decided_at?: number }
export interface Followup { mode?: string; status?: string; sender_task_id?: string; body?: string }
export interface PeerMessage { task_id?: string; mode?: string; status?: string; body?: string }
export interface Automation { id: string; name?: string; status?: string; due?: boolean; interval_seconds?: number; next_run_at?: number }
export interface Member { user_id: string; username?: string; role?: string }
export interface SkillItem { name?: string; id?: string; description?: string; detail?: string }
export interface McpServer { name?: string; id?: string; status?: string; detail?: string; details?: string; state?: string }
export interface TraceEntry { id?: string; kind?: string; command?: string; exitCode?: number | string; durationMs?: number | string; cwd?: string; path?: string; change?: string; status?: string; phase?: string; text?: string; summary?: string; server?: string; tool?: string; name?: string; prompt?: string }
export interface Agent { kind?: string; name?: string; status?: string; prompt?: string; updated_ts?: number; calls?: TraceEntry[] }
export interface ThreadInfo { id?: string; name?: string; thread_name?: string; archived?: boolean | null; cwd?: string; turns?: number; turn_count?: number; turns_count?: number }
export interface Triage {
counts?: { automations_due?: number; failed_tasks?: number; pending_followups?: number; recent_runs?: number };
automations_due?: Array<{ name?: string; interval_seconds?: number }>;
failed_tasks?: Array<{ id?: string; status?: string; prompt?: string }>;
pending_followups?: Array<{ task_id?: string; body?: string }>;
recent_runs?: Array<{ automation_id?: string; status?: string; task_id?: string; task_status?: string }>;
}
export const TRACE_KIND: Record<string, string> = { exec: "命令", file: "文件", call: "调用", tool: "工具", message: "消息", reasoning: "思考", plan: "计划", "agent-start": "子代理", "agent-done": "子代理" };
export const TRACE_LIMIT = 1000;
export const TRACE_TERMINAL = ["completed", "failed", "interrupted", "merged", "merge-failed"];
export const THREAD_MUTATION_STATUSES = ["completed", "failed", "interrupted"];
export const STATUS_EXPLAIN: Record<string, string> = {
queued: "排队中,尚未被 worker 认领", running: "运行中,模型回合进行中",
completed: "模型回合结束、改动在 worktree 里尚未合并", failed: "模型回合失败,可重试",
interrupted: "已被取消/中断,可重试", merged: "控制面已标记 merged,不代表 git 主分支已合入",
"merge-failed": "合并落盘失败,可重试",
};
export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
export const ROLES = ["viewer", "contributor", "maintainer", "owner"];
// Form bodies sent to the server. No section builds these from loose any.
export interface DispatchBody {
project_id: string; prompt: string; environment_id?: string; executor?: string;
model?: string; effort?: string; child_model?: string; child_effort?: string;
approval?: string; plan?: number; goal_mode?: number; upload_id?: string;
coordinator_parts?: Array<{ name: string; prompt?: string }>;
}
export interface ContinueBody {
message: string; model?: string; effort?: string; child_model?: string;
child_effort?: string; approval?: string; plan?: number; goal_mode?: number;
}
export interface ReviewBody { target_type: string; branch?: string; sha?: string; instructions?: string }
export interface Health { backend?: string; db_ok?: boolean; worker_ok?: boolean; commit?: string }
export interface Quota {
used_usd_micros?: number; quota_usd_micros?: number | null; remaining_usd_micros?: number | null;
unpriced_token_cap?: number; unpriced_tokens_used?: number;
}
export interface GithubRepo { clone_url: string; repo_full_name: string }
export interface CostResponse { [k: string]: unknown }