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

+319
View File
@@ -0,0 +1,319 @@
import { useCallback, useRef, useState } from "react";
import { api, getKey, saveKey, clearKey, memberErr, adminErr, fmtUsd } from "../api/client";
import { paths } from "../api/paths";
import type { Env, GithubRepo, Health, Member, Project, Quota, SkillItem, McpServer, Worker } from "../api/types";
// Project-scoped catalogue data: key/quota/health, projects, workers,
// environments, members, skills/MCP, relay models, GitHub repos, admin.
// No task-detail state lives here; see useTaskDetail.
export function useCatalog() {
const [keySaved, setKeySaved] = useState(!!getKey());
const [keyState, setKeyState] = useState(getKey() ? "已保存到本浏览器(只用于调本站 API)" : "未填 key,数据接口会返回 401");
const [quotaText, setQuotaText] = useState("未加载");
const [healthText, setHealthText] = useState("未加载");
const [adminGate, setAdminGate] = useState("填 key 后判断权限…");
const [adminBody, setAdminBody] = useState(false);
const [adminResult, setAdminResult] = useState("");
const [adminUserId, setAdminUserId] = useState("");
const [projects, setProjects] = useState<Project[]>([]);
const [projectId, setProjectId] = useState("");
const [projectMsg, setProjectMsg] = useState("");
const [githubRepos, setGithubRepos] = useState<GithubRepo[]>([]);
const [workers, setWorkers] = useState<Worker[] | null>(null);
const [workersSummary, setWorkersSummary] = useState("未加载");
const [workersError, setWorkersError] = useState("");
const [members, setMembers] = useState<Member[]>([]);
const [memberMsg, setMemberMsg] = useState("");
const [memberError, setMemberError] = useState("");
const [skills, setSkills] = useState<SkillItem[]>([]);
const [apps, setApps] = useState<SkillItem[]>([]);
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
const [skillMsg, setSkillMsg] = useState("先选项目");
const [skillError, setSkillError] = useState("");
const [mcpMsg, setMcpMsg] = useState("");
const [mcpOauthMsg, setMcpOauthMsg] = useState("");
const [mcpOauthBox, setMcpOauthBox] = useState("");
const [envs, setEnvs] = useState<Env[]>([]);
const [envCount, setEnvCount] = useState("");
const [envMsg, setEnvMsg] = useState("");
const [envRepoLine, setEnvRepoLine] = useState("");
const [envRepoBar, setEnvRepoBar] = useState(false);
const [envRepoMsg, setEnvRepoMsg] = useState("");
const [envSelect, setEnvSelect] = useState("");
const [models, setModels] = useState<string[]>([]);
const [modelCustomVisible, setModelCustomVisible] = useState(false);
const projectIdRef = useRef("");
projectIdRef.current = projectId;
const envSelectRef = useRef("");
envSelectRef.current = envSelect;
const refreshHealth = useCallback(async () => {
try {
const h = await api<Health>(paths.health);
setHealthText("backend=" + h.backend + " db_ok=" + h.db_ok + " worker_ok=" + h.worker_ok + " commit=" + String(h.commit || "").slice(0, 7));
} catch (e: any) { setHealthText("加载失败:" + e.message); }
}, []);
const loadQuota = useCallback(async () => {
if (!getKey()) { setQuotaText("未填 key,配额不显示"); return; }
try {
const d = await api<Quota>(paths.quotaMe);
const used = ((d.used_usd_micros || 0) / 1000000).toFixed(2);
const usdLine = d.quota_usd_micros === null || d.quota_usd_micros === undefined
? "美元:本月用量 $" + used + "(不限额)"
: "美元:本月用量 $" + used + " / $" + ((d.quota_usd_micros || 0) / 1000000).toFixed(2) + "(剩余 $" + ((d.remaining_usd_micros || 0) / 1000000).toFixed(2) + ")";
const tokLine = d.unpriced_token_cap === undefined || d.unpriced_tokens_used === undefined
? "无价 token:未返回(旧服务端)"
: "无价 token:" + d.unpriced_tokens_used + " / " + d.unpriced_token_cap + " tokens";
setQuotaText(usdLine + " | " + tokLine);
} catch (e: any) { setQuotaText("配额加载失败:" + e.message); }
}, []);
const probeAdmin = useCallback(async () => {
if (!getKey()) { setAdminGate("未填 key,管理区不可用"); setAdminBody(false); return; }
try {
const r = await fetch(paths.adminQuotas, { headers: { "X-API-Key": getKey() } });
if (r.status === 400) { setAdminGate("已确认为全局管理员,管理操作可用"); setAdminBody(true); }
else if (r.status === 403) { setAdminGate("管理区仅全局管理员可用:当前 key 无全局管理员权限(服务端返回 403)"); setAdminBody(false); }
else if (r.status === 401) { setAdminGate("key 无效,管理区不可用(服务端返回 401)"); setAdminBody(false); }
else { setAdminGate("管理权限判断失败(HTTP " + r.status + "),管理操作暂不可用"); setAdminBody(false); }
} catch (e: any) { setAdminGate("管理权限判断失败:" + e.message); setAdminBody(false); }
}, []);
const refreshProjects = useCallback(async () => {
try {
const d = await api<{ projects: Project[] }>(paths.projects);
const list = d.projects || [];
setProjects(list);
setProjectMsg(list.length + " 个项目");
if (projectIdRef.current && !list.some((p) => p.id === projectIdRef.current)) {
// Selection is preserved when still present; a deleted project
// simply leaves the select empty on next render via value.
}
} catch (e: any) { setProjectMsg("加载失败:" + e.message); }
}, []);
const loadGithubRepos = useCallback(async () => {
try {
const d = await api<{ repos: GithubRepo[] }>(paths.githubRepos);
setGithubRepos(d.repos || []);
} catch { /* 未登录或无安装记录时只留手动输入,不报错 */ }
}, []);
const loadRelayModels = useCallback(async () => {
try {
const d = await api<{ models: string[] }>(paths.relayModels);
setModels(d.models || []);
setModelCustomVisible(false);
} catch { setModelCustomVisible(true); }
}, []);
const refreshWorkers = useCallback(async () => {
try {
const d = await api<{ workers?: Worker[]; workers_total?: number; workers_online?: number; workers_offline?: number } | Worker[]>(paths.workers);
if (!Array.isArray(d) && d.workers === undefined && d.workers_total !== undefined) {
setWorkers(null);
setWorkersSummary(!d.workers_total ? "无 worker 注册" : "在线 " + d.workers_online + " / 离线 " + d.workers_offline + "(共 " + d.workers_total + ";完整 worker 清单仅全局管理员可见)");
setWorkersError("");
return;
}
const list = Array.isArray(d) ? d : (d.workers || []);
setWorkers(list);
setWorkersSummary(list.length ? "" : "无 worker 注册");
setWorkersError("");
} catch (e: any) { setWorkersError("加载失败:" + e.message); }
}, []);
const refreshEnvs = useCallback(async (pid?: string) => {
const id = pid !== undefined ? pid : projectIdRef.current;
const cur = envSelectRef.current;
if (!id) { setEnvs([]); setEnvCount(""); setEnvSelect(""); return; }
try {
const d = await api<{ environments: Env[] }>(paths.environments(id));
const list = d.environments || [];
setEnvs(list);
setEnvCount(list.length + " 个环境");
if (cur && list.some((e) => e.id === cur && e.status === "ready")) setEnvSelect(cur);
else if (cur && list.some((e) => e.id === cur)) setEnvSelect(cur);
const proj = projects.find((p) => p.id === id);
if (proj && proj.repo_url) { setEnvRepoLine("仓库:" + proj.repo_url); setEnvRepoBar(false); }
else if (proj) { setEnvRepoLine("该项目未绑定仓库,环境无法创建——先选一个仓库保存"); setEnvRepoBar(true); }
else { setEnvRepoLine(""); setEnvRepoBar(false); }
} catch (e: any) { setEnvMsg("加载失败:" + e.message); }
}, [projects]);
const refreshMembers = useCallback(async (pid?: string) => {
const id = pid !== undefined ? pid : projectIdRef.current;
if (!id) { setMembers([]); return; }
try {
const d = await api<{ members: Member[] }>(paths.projectMembers(id));
setMembers(d.members || []);
setMemberError("");
} catch (e: any) { setMemberError("成员加载失败:" + memberErr(e)); }
}, []);
const refreshSkills = useCallback(async (pid?: string) => {
const id = pid !== undefined ? pid : projectIdRef.current;
if (!id) { setSkills([]); setApps([]); setMcpServers([]); setSkillMsg("先选项目"); return; }
setSkillMsg("");
try {
const s = await api<{ skills: SkillItem[] }>(paths.projectSkills(id));
setSkills(s.skills || []);
} catch (e: any) { setSkillError("技能加载失败:" + e.message); }
try {
const a = await api<{ apps: SkillItem[] }>(paths.projectApps(id));
setApps(a.apps || []);
} catch (e: any) { setSkillError("应用加载失败:" + e.message); }
try {
const m = await api<{ servers?: McpServer[]; mcp_servers?: McpServer[] }>(paths.projectMcp(id));
setMcpServers(m.servers || m.mcp_servers || []);
} catch (e: any) { setSkillError("MCP 服务加载失败:" + e.message); }
}, []);
const saveKeyInput = useCallback((v: string) => {
if (!v.trim()) { setKeyState("key 为空,未保存"); return; }
saveKey(v.trim());
setKeySaved(true);
setKeyState("已保存到本浏览器(只用于调本站 API)");
}, []);
const clearKeyInput = useCallback(() => {
clearKey();
setKeySaved(false);
setKeyState("未填 key,数据接口会返回 401");
void probeAdmin();
}, [probeAdmin]);
const createProject = useCallback(async (name: string, repo: string) => {
if (!name.trim()) { setProjectMsg("项目名必填"); return; }
const body: { name: string; repo_url?: string } = { name: name.trim() };
if (repo.trim()) body.repo_url = repo.trim();
try {
await api(paths.projects, "POST", body);
setProjectMsg("项目已建");
await refreshProjects();
} catch (e: any) { setProjectMsg("新建失败:" + e.message); }
}, [refreshProjects]);
const createEnv = useCallback(async (name: string, branch: string, executor: string) => {
const pid = projectIdRef.current;
if (!pid) { setEnvMsg("先选项目"); return; }
if (!name.trim()) { setEnvMsg("环境名必填"); return; }
const body: { project_id: string; name: string; branch?: string; executor?: string } = { project_id: pid, name: name.trim() };
if (branch.trim()) body.branch = branch.trim();
if (executor) body.executor = executor;
try {
await api(paths.environmentsCreate, "POST", body);
setEnvMsg("环境已建,worker 准备中(creating → ready,失败会在列表里写原因)");
await refreshEnvs(pid);
} catch (e: any) { setEnvMsg("新建失败:" + e.message); }
}, [refreshEnvs]);
const archiveEnv = useCallback(async (id: string) => {
if (!window.confirm("确定归档该环境吗?归档后新任务不能再选它,进行中的任务不受影响(有进行中任务在用它时归档会被拒绝)。")) return;
try { await api(paths.environmentArchive(id), "POST", {}); await refreshEnvs(); }
catch (e: any) { setEnvMsg("归档失败:" + e.message); }
}, [refreshEnvs]);
const saveEnvRepo = useCallback(async (repo: string) => {
const pid = projectIdRef.current;
if (!pid) { setEnvRepoMsg("先选项目"); return; }
if (!repo) { setEnvRepoMsg("先选一个仓库"); return; }
try {
await api(paths.project(pid), "PATCH", { repo_url: repo });
setEnvRepoMsg("仓库已保存,现在可以建环境了");
await refreshProjects();
await refreshEnvs(pid);
} catch (e: any) { setEnvRepoMsg("保存失败:" + e.message); }
}, [refreshProjects, refreshEnvs]);
const inviteMember = useCallback(async (raw: string, role: string) => {
const pid = projectIdRef.current;
if (!pid) { setMemberMsg("先选项目"); return; }
if (!raw.trim()) { setMemberMsg("请填成员 user_id 或用户名"); return; }
try {
await api(paths.projectMembers(pid), "POST", { user_id: raw.trim(), role });
} catch (e: any) {
if (e.message.includes("user not found")) {
try { await api(paths.projectMembers(pid), "POST", { username: raw.trim(), role }); }
catch (e2: any) { setMemberMsg(memberErr(e2)); return; }
} else { setMemberMsg(memberErr(e)); return; }
}
setMemberMsg("已邀请成员");
await refreshMembers(pid);
}, [refreshMembers]);
const changeMemberRole = useCallback(async (uid: string, role: string) => {
const pid = projectIdRef.current;
if (!pid) return;
try {
await api(paths.projectMember(pid, uid), "PATCH", { role });
setMembers((prev) => prev.map((m) => (m.user_id === uid ? { ...m, role } : m)));
setMemberMsg("角色已改为 " + role);
} catch (e: any) { setMemberMsg(memberErr(e)); }
}, []);
const reloadMcp = useCallback(async () => {
const pid = projectIdRef.current;
if (!pid) { setMcpMsg("先选项目"); return; }
try {
await api(paths.projectMcpReload(pid), "POST", {});
setMcpMsg("配置已重载");
await refreshSkills(pid);
} catch (e: any) { setMcpMsg("重载失败:" + e.message); }
}, [refreshSkills]);
const mcpOauth = useCallback(async (name: string) => {
const pid = projectIdRef.current;
if (!pid) { setMcpOauthMsg("先选项目"); return; }
if (!name.trim()) { setMcpOauthMsg("服务名不能为空"); return; }
try {
const d = await api<{ url?: string; auth_url?: string; login_url?: string; authorization_url?: string; steps?: string | string[]; instructions?: string | string[] } & Record<string, unknown>>(paths.projectMcpOauth(pid, name.trim()), "POST", {});
const url = d.url || d.auth_url || d.login_url || d.authorization_url || "";
const steps = d.steps || d.instructions || "";
let box = "登录尚未完成:请在浏览器打开下面的链接或按步骤完成,完成后回到本页刷新 MCP 状态。\n";
if (url) box += url + "\n";
if (steps) box += (Array.isArray(steps) ? steps.join("\n") : String(steps)) + "\n";
box += JSON.stringify(d, null, 2).slice(0, 4000);
setMcpOauthBox(box);
setMcpOauthMsg("已返回登录信息,见下方");
} catch (e: any) { setMcpOauthMsg("发起失败:" + e.message); }
}, []);
const adminQuota = useCallback(async () => {
const uid = adminUserId.trim();
if (!uid) { setAdminResult("请填要查配额的用户 id"); return; }
try {
const d = await api<{ used_usd_micros?: number; quota_usd_micros?: number | null; remaining_usd_micros?: number | null; month?: string }>(paths.adminQuotaUser(uid));
setAdminResult("用户 " + uid.slice(0, 8) + ":本月用量 " + fmtUsd(d.used_usd_micros) + " / 配额 " + fmtUsd(d.quota_usd_micros) + (d.remaining_usd_micros !== null && d.remaining_usd_micros !== undefined ? "(剩余 " + fmtUsd(d.remaining_usd_micros) + ")" : "") + " | 月份 " + (d.month || ""));
} catch (e: any) { setAdminResult(adminErr(e)); }
}, [adminUserId]);
const adminSweep = useCallback(async () => {
if (!window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。")) return;
try {
const d = await api<{ swept?: string[] }>(paths.adminSweep, "POST", {});
const swept = d.swept || [];
setAdminResult("sweep 完成,清理 " + swept.length + " 个任务" + (swept.length ? ":" + swept.join("、") : ""));
} catch (e: any) { setAdminResult(adminErr(e)); }
}, []);
const adminBackup = useCallback(async () => {
try {
const d = await api<{ backend?: string; path?: string; size_bytes?: number; task_count?: number }>(paths.adminBackup, "POST", {});
setAdminResult("备份完成:backend=" + d.backend + " | 路径 " + d.path + " | 大小 " + d.size_bytes + " 字节 | 任务 " + d.task_count + " 行");
} catch (e: any) { setAdminResult(adminErr(e)); }
}, []);
return {
keySaved, keyState, quotaText, healthText, adminGate, adminBody, adminResult, adminUserId, setAdminUserId,
projects, projectId, setProjectId, projectMsg, githubRepos,
workers, workersSummary, workersError, members, memberMsg, memberError,
skills, apps, mcpServers, skillMsg, skillError, mcpMsg, mcpOauthMsg, mcpOauthBox,
envs, envCount, envMsg, envRepoLine, envRepoBar, envRepoMsg, envSelect, setEnvSelect,
models, modelCustomVisible,
refreshHealth, loadQuota, probeAdmin, refreshProjects, loadGithubRepos, loadRelayModels, refreshWorkers,
refreshEnvs, refreshMembers, refreshSkills, saveKeyInput, clearKeyInput, createProject, createEnv,
archiveEnv, saveEnvRepo, inviteMember, changeMemberRole, reloadMcp, mcpOauth, adminQuota, adminSweep, adminBackup,
};
}