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([]); const [projectId, setProjectId] = useState(""); const [projectMsg, setProjectMsg] = useState(""); const [githubRepos, setGithubRepos] = useState([]); const [workers, setWorkers] = useState(null); const [workersSummary, setWorkersSummary] = useState("未加载"); const [workersError, setWorkersError] = useState(""); const [members, setMembers] = useState([]); const [memberMsg, setMemberMsg] = useState(""); const [memberError, setMemberError] = useState(""); const [skills, setSkills] = useState([]); const [apps, setApps] = useState([]); const [mcpServers, setMcpServers] = useState([]); const [skillMsg, setSkillMsg] = useState("先选项目"); const [skillError, setSkillError] = useState(""); const [mcpMsg, setMcpMsg] = useState(""); const [mcpOauthMsg, setMcpOauthMsg] = useState(""); const [mcpOauthBox, setMcpOauthBox] = useState(""); const [envs, setEnvs] = useState([]); 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([]); 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(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(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>(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, }; }