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:
1 parent
3f4e849a93
commit
ebc5624893
57 files changed
+4789
-1746
No files matched your search
@@ -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, "&").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("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;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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 }
|
||||
|
||||
Reference in new issue
Block a user