Files
codex-app-server/cloud/console-react/src/hooks/useCatalog.ts
T
host ebc5624893 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 标记单测未动且通过。
2026-10-29 06:32:38 +08:00

320 lines
17 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.
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,
};
}