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,32 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// Auto-refresh driver. The callback receives whether a <select> currently
|
||||
// has focus; callers must skip select-rebuilding loads in that case, so a
|
||||
// refresh never yanks a dropdown the user is choosing from. Form state
|
||||
// lives in React state and is never overwritten by a refresh, so a form
|
||||
// being typed is never cleared.
|
||||
export function useAutoRefresh(tick: (selectFocused: boolean) => void, ms: number) {
|
||||
const cb = useRef(tick);
|
||||
cb.current = tick;
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
const ae = document.activeElement;
|
||||
cb.current(ae ? ae.tagName === "SELECT" : false);
|
||||
}, ms);
|
||||
return () => clearInterval(id);
|
||||
}, [ms]);
|
||||
}
|
||||
|
||||
export function useIntervalMs(cb: () => void, ms: number) {
|
||||
const ref = useRef(cb);
|
||||
ref.current = cb;
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => ref.current(), ms);
|
||||
return () => clearInterval(id);
|
||||
}, [ms]);
|
||||
}
|
||||
|
||||
export function formatRefreshTime(d: Date): string {
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getKey } from "../api/client";
|
||||
import { useCatalog } from "./useCatalog";
|
||||
import { useTaskList } from "./useTaskList";
|
||||
import { useTaskDetail } from "./useTaskDetail";
|
||||
import { useProjectTools } from "./useProjectTools";
|
||||
import { formatRefreshTime } from "./useAutoRefresh";
|
||||
import type { ConsoleCtx } from "../context";
|
||||
|
||||
// Glue only: composes the domain hooks into one ConsoleCtx and owns
|
||||
// the auto-refresh policy. No API calls and no rendering live here.
|
||||
export function useConsoleStore(): ConsoleCtx {
|
||||
const catalog = useCatalog();
|
||||
const taskList = useTaskList();
|
||||
const projectIdRef = useRef("");
|
||||
projectIdRef.current = catalog.projectId;
|
||||
const getProjectId = useCallback(() => projectIdRef.current, []);
|
||||
const tools = useProjectTools(getProjectId);
|
||||
const getRole = useCallback((pid?: string) => {
|
||||
const p = catalog.projects.find((x) => x.id === pid);
|
||||
return p ? (p.my_role || null) : null;
|
||||
}, [catalog.projects]);
|
||||
const onTasksReload = useCallback(() => { void taskList.refreshTasks(undefined, projectIdRef.current); }, [taskList]);
|
||||
const detail = useTaskDetail(getRole, onTasksReload);
|
||||
const [refreshStamp, setRefreshStamp] = useState("--:--:--");
|
||||
|
||||
const selectProject = useCallback((id: string) => {
|
||||
catalog.setProjectId(id);
|
||||
projectIdRef.current = id;
|
||||
void taskList.refreshTasks(id);
|
||||
void catalog.refreshEnvs(id);
|
||||
void catalog.refreshMembers(id);
|
||||
void catalog.refreshSkills(id);
|
||||
void tools.refreshAutomations(id);
|
||||
void tools.refreshTriage(id);
|
||||
}, [catalog, taskList, tools]);
|
||||
|
||||
const saveKeyInput = useCallback((v: string) => {
|
||||
catalog.saveKeyInput(v);
|
||||
if (!v.trim()) return;
|
||||
// Same reload set as the live saveKey handler.
|
||||
void catalog.refreshHealth();
|
||||
void catalog.refreshProjects();
|
||||
void catalog.refreshWorkers();
|
||||
if (projectIdRef.current) {
|
||||
void taskList.refreshTasks(projectIdRef.current);
|
||||
void catalog.refreshEnvs(projectIdRef.current);
|
||||
void catalog.refreshMembers(projectIdRef.current);
|
||||
}
|
||||
void catalog.loadRelayModels();
|
||||
void catalog.loadQuota();
|
||||
void catalog.loadGithubRepos();
|
||||
void catalog.probeAdmin();
|
||||
}, [catalog, taskList]);
|
||||
|
||||
const refreshAll = useCallback((selectFocused: boolean) => {
|
||||
setRefreshStamp(formatRefreshTime(new Date()));
|
||||
if (!getKey()) { void catalog.refreshHealth(); return; }
|
||||
void taskList.refreshTasks(undefined, projectIdRef.current);
|
||||
void catalog.refreshHealth();
|
||||
void catalog.loadQuota();
|
||||
void catalog.refreshWorkers();
|
||||
void catalog.refreshMembers();
|
||||
void catalog.refreshSkills();
|
||||
void tools.refreshAutomations();
|
||||
void tools.refreshTriage();
|
||||
void detail.refreshDetailLists();
|
||||
if (!selectFocused) {
|
||||
void catalog.refreshProjects();
|
||||
void catalog.refreshEnvs();
|
||||
}
|
||||
}, [catalog, taskList, tools, detail]);
|
||||
|
||||
const refreshSlow = useCallback(() => {
|
||||
if (!getKey()) return;
|
||||
void catalog.loadRelayModels();
|
||||
void catalog.loadGithubRepos();
|
||||
}, [catalog]);
|
||||
|
||||
// Initial load, mirroring the live file's boot sequence.
|
||||
useEffect(() => {
|
||||
void catalog.refreshHealth();
|
||||
void catalog.refreshProjects();
|
||||
void catalog.refreshWorkers();
|
||||
void catalog.loadQuota();
|
||||
void catalog.loadGithubRepos();
|
||||
if (getKey()) void catalog.loadRelayModels();
|
||||
void catalog.probeAdmin();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return {
|
||||
keySaved: catalog.keySaved, keyState: catalog.keyState,
|
||||
saveKeyInput, clearKeyInput: catalog.clearKeyInput,
|
||||
quotaText: catalog.quotaText,
|
||||
healthText: catalog.healthText, refreshHealth: catalog.refreshHealth,
|
||||
adminGate: catalog.adminGate, adminBody: catalog.adminBody, adminResult: catalog.adminResult,
|
||||
adminUserId: catalog.adminUserId, setAdminUserId: catalog.setAdminUserId,
|
||||
adminQuota: catalog.adminQuota, adminSweep: catalog.adminSweep, adminBackup: catalog.adminBackup,
|
||||
projects: catalog.projects, projectId: catalog.projectId, projectMsg: catalog.projectMsg,
|
||||
selectProject, refreshProjects: catalog.refreshProjects,
|
||||
createProject: catalog.createProject,
|
||||
githubRepos: catalog.githubRepos,
|
||||
workers: catalog.workers, workersSummary: catalog.workersSummary, workersError: catalog.workersError, refreshWorkers: catalog.refreshWorkers,
|
||||
members: catalog.members, memberMsg: catalog.memberMsg, memberError: catalog.memberError,
|
||||
refreshMembers: () => void catalog.refreshMembers(), inviteMember: catalog.inviteMember, changeMemberRole: catalog.changeMemberRole,
|
||||
skills: catalog.skills, apps: catalog.apps, mcpServers: catalog.mcpServers, skillMsg: catalog.skillMsg, skillError: catalog.skillError,
|
||||
mcpMsg: catalog.mcpMsg, mcpOauthMsg: catalog.mcpOauthMsg, mcpOauthBox: catalog.mcpOauthBox,
|
||||
refreshSkills: () => void catalog.refreshSkills(), reloadMcp: catalog.reloadMcp, mcpOauth: catalog.mcpOauth,
|
||||
envs: catalog.envs, envCount: catalog.envCount, envMsg: catalog.envMsg, envRepoLine: catalog.envRepoLine, envRepoBar: catalog.envRepoBar, envRepoMsg: catalog.envRepoMsg, envSelect: catalog.envSelect, setEnvSelect: catalog.setEnvSelect,
|
||||
refreshEnvs: () => void catalog.refreshEnvs(), createEnv: catalog.createEnv, archiveEnv: catalog.archiveEnv, saveEnvRepo: catalog.saveEnvRepo,
|
||||
models: catalog.models, modelCustomVisible: catalog.modelCustomVisible,
|
||||
tasks: taskList.tasks, taskCountText: taskList.taskCountText, taskError: taskList.taskError,
|
||||
refreshTasks: () => void taskList.refreshTasks(undefined, projectIdRef.current),
|
||||
statusFilter: taskList.statusFilter, setStatusFilter: taskList.setStatusFilter,
|
||||
executorFilter: taskList.executorFilter, setExecutorFilter: taskList.setExecutorFilter,
|
||||
searchText: taskList.searchText, setSearchText: taskList.setSearchText,
|
||||
filteredTasks: taskList.filteredTasks,
|
||||
currentTask: detail.currentTask, detailBody: detail.detailBody, actionMsg: detail.actionMsg, actionReasons: detail.actionReasons,
|
||||
showDetail: (id: string) => void detail.showDetail(id),
|
||||
doAction: detail.doAction, taskCost: detail.taskCost, costBox: detail.costBox,
|
||||
forkVisible: detail.forkVisible, setForkVisible: detail.setForkVisible, forkTask: detail.forkTask, forkMsg: detail.forkMsg,
|
||||
coordChildren: detail.coordChildren, coordText: "", agents: detail.agents, agentsNote: detail.agentsNote,
|
||||
reviews: detail.reviews, reviewMsg: detail.reviewMsg, reviewError: detail.reviewError,
|
||||
refreshReviews: detail.refreshReviews, postReview: detail.postReview, submitFindings: detail.submitFindings,
|
||||
traceEntries: detail.traceEntries, traceNote: detail.traceNote, traceDone: detail.traceDone,
|
||||
traceKind: detail.traceKind, setTraceKind: detail.setTraceKind, downloadTrace: detail.downloadTrace, traceMsg: detail.traceMsg,
|
||||
sendChatMessage: detail.sendChatMessage, chatMsg: detail.chatMsg,
|
||||
followups: detail.followups,
|
||||
approvals: detail.approvals, approvalMsg: detail.approvalMsg, refreshApprovals: detail.refreshApprovals, decideApproval: detail.decideApproval,
|
||||
peerMessages: detail.peerMessages, peerMsg: detail.peerMsg, postPeer: detail.postPeer,
|
||||
automations: tools.automations, autoMsg: tools.autoMsg, refreshAutomations: () => void tools.refreshAutomations(), createAutomation: tools.createAutomation, cancelAutomation: tools.cancelAutomation,
|
||||
triage: tools.triage, triageError: tools.triageError, refreshTriage: () => void tools.refreshTriage(),
|
||||
threadText: detail.threadText, threadGateNote: detail.threadGateNote, threadMsg: detail.threadMsg, threadList: detail.threadList, threadAction: detail.threadAction,
|
||||
dispatch: async (body, atts) => {
|
||||
const msg = await taskList.dispatch(body, atts);
|
||||
void taskList.refreshTasks(projectIdRef.current);
|
||||
return msg;
|
||||
},
|
||||
dispatchMsg: "",
|
||||
refreshAll, refreshSlow, refreshStamp,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import { paths } from "../api/paths";
|
||||
import type { Automation, Triage } from "../api/types";
|
||||
|
||||
// Project-scoped tools shown in the centre for their own views:
|
||||
// automations (create/list/cancel) and the triage inbox (4 groups).
|
||||
export function useProjectTools(getProjectId: () => string) {
|
||||
const [automations, setAutomations] = useState<Automation[]>([]);
|
||||
const [autoMsg, setAutoMsg] = useState("");
|
||||
const [triage, setTriage] = useState<Triage | null>(null);
|
||||
const [triageError, setTriageError] = useState("");
|
||||
|
||||
const refreshAutomations = useCallback(async (pid?: string) => {
|
||||
const id = pid !== undefined ? pid : getProjectId();
|
||||
if (!id) { setAutomations([]); return; }
|
||||
try {
|
||||
const d = await api<{ automations: Automation[] }>(paths.projectAutomations(id));
|
||||
setAutomations(d.automations || []);
|
||||
} catch (e: any) { setAutoMsg("自动化加载失败:" + e.message); }
|
||||
}, [getProjectId]);
|
||||
|
||||
const createAutomation = useCallback(async (name: string, prompt: string, interval: string, repo: string) => {
|
||||
const pid = getProjectId();
|
||||
if (!pid) { setAutoMsg("先选项目"); return; }
|
||||
if (!name.trim()) { setAutoMsg("自动化名称必填"); return; }
|
||||
if (!prompt.trim()) { setAutoMsg("定时任务内容必填"); return; }
|
||||
const body: { name: string; prompt: string; interval_seconds?: number; repo_url?: string } = { name: name.trim(), prompt: prompt.trim() };
|
||||
if (interval.trim()) body.interval_seconds = parseInt(interval.trim(), 10);
|
||||
if (repo.trim()) body.repo_url = repo.trim();
|
||||
try {
|
||||
await api(paths.projectAutomations(pid), "POST", body);
|
||||
setAutoMsg("自动化已创建(登记定义,到期后由执行器认领)");
|
||||
await refreshAutomations(pid);
|
||||
} catch (e: any) { setAutoMsg("创建失败:" + e.message); }
|
||||
}, [getProjectId, refreshAutomations]);
|
||||
|
||||
const cancelAutomation = useCallback((id: string) => {
|
||||
const pid = getProjectId();
|
||||
if (!pid) return;
|
||||
void api(paths.projectAutomationCancel(pid, id), "POST", {})
|
||||
.then(() => { setAutoMsg("自动化已取消"); void refreshAutomations(pid); })
|
||||
.catch((e: any) => setAutoMsg("取消失败:" + e.message));
|
||||
}, [getProjectId, refreshAutomations]);
|
||||
|
||||
const refreshTriage = useCallback(async (pid?: string) => {
|
||||
const id = pid !== undefined ? pid : getProjectId();
|
||||
if (!id) { setTriage(null); return; }
|
||||
try {
|
||||
const d = await api<Triage>(paths.projectTriage(id));
|
||||
setTriage(d);
|
||||
setTriageError("");
|
||||
} catch (e: any) { setTriageError("分诊加载失败:" + e.message); }
|
||||
}, [getProjectId]);
|
||||
|
||||
return { automations, autoMsg, triage, triageError, refreshAutomations, createAutomation, cancelAutomation, refreshTriage };
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api, getKey } from "../api/client";
|
||||
import { paths } from "../api/paths";
|
||||
import { TRACE_TERMINAL } from "../api/types";
|
||||
import type { Agent, Approval, Followup, PeerMessage, Review, Task, ThreadInfo, TraceEntry } from "../api/types";
|
||||
|
||||
// Task detail state: the selected task, its trace SSE/one-shot stream,
|
||||
// and every structured sub-list. Chat sending lives here too:
|
||||
// terminal -> /continue, queued/running -> /followups (direct).
|
||||
export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksReload: () => void) {
|
||||
const [currentTask, setCurrentTask] = useState<Task | null>(null);
|
||||
const [detailBody, setDetailBody] = useState("");
|
||||
const [actionMsg, setActionMsg] = useState("");
|
||||
const [actionReasons, setActionReasons] = useState("");
|
||||
const [costBox, setCostBox] = useState("");
|
||||
const [forkVisible, setForkVisible] = useState(false);
|
||||
const [forkMsg, setForkMsg] = useState("");
|
||||
const [coordChildren, setCoordChildren] = useState<Task[]>([]);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [agentsNote, setAgentsNote] = useState("");
|
||||
const [reviews, setReviews] = useState<Review[]>([]);
|
||||
const [reviewMsg, setReviewMsg] = useState("");
|
||||
const [reviewError, setReviewError] = useState("");
|
||||
const [traceEntries, setTraceEntries] = useState<TraceEntry[]>([]);
|
||||
const [traceNote, setTraceNote] = useState("");
|
||||
const [traceDone, setTraceDone] = useState("");
|
||||
const [traceKind, setTraceKind] = useState("");
|
||||
const [traceMsg, setTraceMsg] = useState("");
|
||||
const [followups, setFollowups] = useState<Followup[]>([]);
|
||||
const [approvals, setApprovals] = useState<Approval[]>([]);
|
||||
const [approvalMsg, setApprovalMsg] = useState("");
|
||||
const [peerMessages, setPeerMessages] = useState<PeerMessage[]>([]);
|
||||
const [peerMsg, setPeerMsg] = useState("");
|
||||
const [threadText, setThreadText] = useState("");
|
||||
const [threadGateNote, setThreadGateNote] = useState("");
|
||||
const [threadMsg, setThreadMsg] = useState("");
|
||||
const [threadList, setThreadList] = useState<ThreadInfo[]>([]);
|
||||
const [chatMsg, setChatMsg] = useState("");
|
||||
const traceAbortRef = useRef<AbortController | null>(null);
|
||||
const agentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const currentTaskRef = useRef<Task | null>(null);
|
||||
currentTaskRef.current = currentTask;
|
||||
|
||||
const stopTrace = useCallback(() => {
|
||||
if (traceAbortRef.current) { try { traceAbortRef.current.abort(); } catch { /* noop */ } traceAbortRef.current = null; }
|
||||
if (agentTimerRef.current) { clearTimeout(agentTimerRef.current); agentTimerRef.current = null; }
|
||||
}, []);
|
||||
|
||||
const loadAgents = useCallback(async (taskId: string) => {
|
||||
try {
|
||||
const d = await api<{ agents?: Agent[]; note?: string }>(paths.taskAgents(taskId));
|
||||
setAgents(d.agents || []);
|
||||
setAgentsNote(d.note || "");
|
||||
} catch (e: any) { setAgents([]); setAgentsNote("子代理链路加载失败:" + e.message); }
|
||||
}, []);
|
||||
|
||||
const scheduleAgents = useCallback((taskId: string) => {
|
||||
if (agentTimerRef.current) clearTimeout(agentTimerRef.current);
|
||||
agentTimerRef.current = setTimeout(() => void loadAgents(taskId), 1000);
|
||||
}, [loadAgents]);
|
||||
|
||||
const loadCoordChildren = useCallback(async (task: Task) => {
|
||||
if (!task.coordinator_parts) { setCoordChildren([]); return; }
|
||||
try {
|
||||
const d = await api<{ tasks: Task[] }>(paths.tasksByParent(task.project_id || "", task.id));
|
||||
setCoordChildren(d.tasks || []);
|
||||
} catch { setCoordChildren([]); }
|
||||
}, []);
|
||||
|
||||
const loadFollowups = useCallback(async (taskId: string) => {
|
||||
try { const d = await api<{ followups: Followup[] }>(paths.taskFollowups(taskId)); setFollowups(d.followups || []); }
|
||||
catch { setFollowups([]); }
|
||||
}, []);
|
||||
|
||||
const loadReviews = useCallback(async (taskId?: string) => {
|
||||
const id = taskId || currentTaskRef.current?.id;
|
||||
if (!id) return;
|
||||
try {
|
||||
const d = await api<{ reviews: Review[] }>(paths.taskReviews(id));
|
||||
setReviews(d.reviews || []);
|
||||
setReviewError("");
|
||||
} catch (e: any) { setReviewError("评审队列加载失败:" + e.message); }
|
||||
}, []);
|
||||
|
||||
const loadApprovals = useCallback(async (taskId?: string) => {
|
||||
const id = taskId || currentTaskRef.current?.id;
|
||||
if (!id) return;
|
||||
try {
|
||||
const d = await api<{ approvals: Approval[] }>(paths.taskApprovals(id));
|
||||
setApprovals(d.approvals || []);
|
||||
} catch (e: any) { setApprovalMsg("审批队列加载失败:" + e.message); }
|
||||
}, []);
|
||||
|
||||
const loadPeerMessages = useCallback(async (taskId: string) => {
|
||||
try { const d = await api<{ messages: PeerMessage[] }>(paths.taskMessages(taskId)); setPeerMessages(d.messages || []); }
|
||||
catch { setPeerMessages([]); }
|
||||
}, []);
|
||||
|
||||
const loadThread = useCallback(async (taskId: string) => {
|
||||
try {
|
||||
const d = await api<{ thread?: ThreadInfo } & ThreadInfo>(paths.taskThread(taskId));
|
||||
const th = d.thread || d;
|
||||
let turns = th.turns;
|
||||
if (turns === undefined || turns === null) turns = th.turn_count;
|
||||
if (turns === undefined || turns === null) turns = th.turns_count;
|
||||
setThreadText("id " + (th.id || taskId) + " | 名字 " + (th.name || th.thread_name || "") + " | 归档 " + (th.archived === undefined || th.archived === null ? "" : (th.archived ? "是" : "否")) + " | cwd " + (th.cwd || "") + (turns !== undefined && turns !== null ? " | turn 数 " + turns : ""));
|
||||
} catch (e: any) { setThreadText("线程加载失败:" + e.message); }
|
||||
}, []);
|
||||
|
||||
const loadThreads = useCallback(async (taskId: string) => {
|
||||
try { const d = await api<{ threads: ThreadInfo[] }>(paths.taskThreads(taskId)); setThreadList(d.threads || []); }
|
||||
catch { setThreadList([]); }
|
||||
}, []);
|
||||
|
||||
const setThreadGate = useCallback((status: string) => {
|
||||
const ok = ["completed", "failed", "interrupted"].includes(status);
|
||||
setThreadGateNote(ok ? "终态任务,线程操作可用" : "仅终态任务可操作线程,当前状态 " + status + ",按钮已置灰");
|
||||
}, []);
|
||||
|
||||
const startTrace = useCallback(async (taskId: string, status: string) => {
|
||||
stopTrace();
|
||||
setTraceEntries([]);
|
||||
setTraceNote("");
|
||||
setTraceDone("");
|
||||
if (TRACE_TERMINAL.includes(status)) {
|
||||
try {
|
||||
const d = await api<{ entries: TraceEntry[]; note?: string; status?: string }>(paths.taskTrace(taskId));
|
||||
setTraceEntries(d.entries || []);
|
||||
setTraceNote(d.note || "");
|
||||
setTraceDone(d.status || "");
|
||||
} catch (e: any) { setTraceNote("链路加载失败:" + e.message); }
|
||||
return;
|
||||
}
|
||||
const ctrl = new AbortController();
|
||||
traceAbortRef.current = ctrl;
|
||||
let entries: TraceEntry[] = [];
|
||||
const index: Record<string, number> = {};
|
||||
let note = "";
|
||||
let done = false;
|
||||
let doneStatus = "";
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
const key = getKey();
|
||||
if (key) headers["X-API-Key"] = key;
|
||||
const r = await fetch(paths.taskTraceStream(taskId), { headers, signal: ctrl.signal });
|
||||
if (!r.ok) {
|
||||
let msg = "HTTP " + r.status;
|
||||
try { const dj = await r.json(); if (dj && dj.error) msg = dj.error; } catch { /* noop */ }
|
||||
if (r.status === 401) msg += "(key 未填或无效)";
|
||||
throw new Error(msg);
|
||||
}
|
||||
const reader = r.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
buf += decoder.decode(chunk.value, { stream: true });
|
||||
const parts = buf.split("\n\n");
|
||||
buf = parts.pop() || "";
|
||||
for (const part of parts) {
|
||||
const line = part.split("\n").filter((l) => l.indexOf("data: ") === 0).map((l) => l.slice(6)).join("\n");
|
||||
if (!line) continue;
|
||||
try {
|
||||
const m = JSON.parse(line);
|
||||
if (m.type === "snapshot") {
|
||||
entries = m.entries || [];
|
||||
Object.keys(index).forEach((k) => delete index[k]);
|
||||
entries.forEach((e: TraceEntry, i: number) => { if (e.id) index[e.id] = i; });
|
||||
note = m.note || "";
|
||||
setTraceEntries([...entries]);
|
||||
setTraceNote(note);
|
||||
scheduleAgents(taskId);
|
||||
} else if (m.type === "entry") {
|
||||
const e = m.entry as TraceEntry;
|
||||
if (e.id && e.id in index) entries[index[e.id]] = e;
|
||||
else { if (e.id) index[e.id] = entries.length; entries.push(e); }
|
||||
setTraceEntries([...entries]);
|
||||
scheduleAgents(taskId);
|
||||
} else if (m.type === "done") {
|
||||
done = true;
|
||||
doneStatus = m.status;
|
||||
setTraceDone(doneStatus);
|
||||
}
|
||||
} catch { /* ignore malformed SSE frame */ }
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e && e.name === "AbortError") return;
|
||||
setTraceNote("链路流中断:" + e.message);
|
||||
return;
|
||||
}
|
||||
if (done) { onTasksReload(); }
|
||||
}, [stopTrace, scheduleAgents, onTasksReload]);
|
||||
|
||||
const showDetail = useCallback(async (taskId: string) => {
|
||||
stopTrace();
|
||||
try {
|
||||
const t = await api<Task>(paths.task(taskId));
|
||||
setCurrentTask(t);
|
||||
let body = "";
|
||||
if (t.result_text) body += "=== result ===\n" + t.result_text.slice(0, 8000) + "\n";
|
||||
if (t.error_text) body += "=== error ===\n" + t.error_text.slice(0, 8000) + "\n";
|
||||
if (!body) body = "(暂无 result/error 输出)";
|
||||
setDetailBody(body);
|
||||
setCostBox("");
|
||||
setForkVisible(false);
|
||||
setForkMsg("");
|
||||
setPeerMsg("");
|
||||
setThreadMsg("");
|
||||
setThreadGate(t.status);
|
||||
// Button gating by role: maintainer/owner for retry/merge/pr,
|
||||
// contributor+ for cancel; the right panel disables with reasons.
|
||||
const role = getRole(t.project_id);
|
||||
const isMaintainer = role === "maintainer" || role === "owner";
|
||||
const canCreate = role === "contributor" || isMaintainer;
|
||||
const terminal = TRACE_TERMINAL.includes(t.status);
|
||||
const reasons: string[] = [];
|
||||
if (!((["failed", "interrupted", "merge-failed"].includes(t.status)) && isMaintainer)) reasons.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试" + (isMaintainer ? "" : ",且仅 maintainer 可重试"));
|
||||
if (!(!terminal && canCreate)) reasons.push("取消:不可用——" + (terminal ? "终态任务不可取消" : "仅项目成员(contributor 及以上)可取消"));
|
||||
if (!(t.status === "completed" && isMaintainer)) reasons.push("合并:不可用——仅 completed 可合并" + (isMaintainer ? "" : ",且仅 maintainer 可合并"));
|
||||
if (!(t.status === "merged" && isMaintainer)) reasons.push("提 PR:不可用——仅 merged 可提 PR" + (isMaintainer ? "" : ",且仅 maintainer 可提 PR"));
|
||||
setActionReasons(reasons.join(";"));
|
||||
setActionMsg("");
|
||||
void startTrace(taskId, t.status);
|
||||
void loadAgents(taskId);
|
||||
void loadCoordChildren(t);
|
||||
void loadFollowups(taskId);
|
||||
void loadPeerMessages(taskId);
|
||||
void loadThread(taskId);
|
||||
void loadThreads(taskId);
|
||||
void loadReviews(taskId);
|
||||
void loadApprovals(taskId);
|
||||
} catch (e: any) {
|
||||
setCurrentTask(null);
|
||||
setDetailBody("加载失败:" + e.message);
|
||||
}
|
||||
}, [stopTrace, startTrace, loadAgents, loadCoordChildren, loadFollowups, loadPeerMessages, loadThread, loadThreads, loadReviews, loadApprovals, getRole]);
|
||||
|
||||
const refreshDetailLists = useCallback(async () => {
|
||||
const t = currentTaskRef.current;
|
||||
if (!t) return;
|
||||
try {
|
||||
const fresh = await api<Task>(paths.task(t.id));
|
||||
setCurrentTask(fresh);
|
||||
let body = "";
|
||||
if (fresh.result_text) body += "=== result ===\n" + fresh.result_text.slice(0, 8000) + "\n";
|
||||
if (fresh.error_text) body += "=== error ===\n" + fresh.error_text.slice(0, 8000) + "\n";
|
||||
setDetailBody(body || "(暂无 result/error 输出)");
|
||||
} catch { /* light header refresh must not clear the detail */ }
|
||||
void loadFollowups(t.id);
|
||||
void loadReviews(t.id);
|
||||
void loadApprovals(t.id);
|
||||
void loadCoordChildren(t);
|
||||
void loadAgents(t.id);
|
||||
void loadPeerMessages(t.id);
|
||||
void loadThread(t.id);
|
||||
void loadThreads(t.id);
|
||||
}, [loadFollowups, loadReviews, loadApprovals, loadCoordChildren, loadAgents, loadPeerMessages, loadThread, loadThreads]);
|
||||
|
||||
const doAction = useCallback(async (kind: string) => {
|
||||
const t = currentTaskRef.current;
|
||||
if (!t) return;
|
||||
const confirmNeeded: Record<string, string> = {
|
||||
cancel: "确定取消该任务吗?", merge: "确定把 completed 任务标为 merged 吗?", pr: "确定为该任务提 PR 吗?(会在仓库真实建 PR)",
|
||||
};
|
||||
if (confirmNeeded[kind] && !window.confirm(confirmNeeded[kind])) return;
|
||||
try {
|
||||
const r = await api<unknown>(paths.taskAction(t.id, kind), "POST", {});
|
||||
setActionMsg(kind + " 已提交:" + JSON.stringify(r).slice(0, 300));
|
||||
onTasksReload();
|
||||
await showDetail(t.id);
|
||||
} catch (e: any) { setActionMsg(kind + " 失败:" + e.message); }
|
||||
}, [showDetail, onTasksReload]);
|
||||
|
||||
const taskCost = useCallback(async () => {
|
||||
const t = currentTaskRef.current;
|
||||
if (!t) return;
|
||||
try {
|
||||
const c = await api<unknown>(paths.taskCost(t.id));
|
||||
setCostBox(JSON.stringify(c, null, 2).slice(0, 4000));
|
||||
} catch (e: any) { setCostBox("查成本失败:" + e.message); }
|
||||
}, []);
|
||||
|
||||
const forkTask = useCallback(async (msg: string) => {
|
||||
const t = currentTaskRef.current;
|
||||
if (!t) return;
|
||||
if (!msg.trim()) { setForkMsg("分支消息必填"); return; }
|
||||
if (!TRACE_TERMINAL.includes(t.status)) { setForkMsg("仅终态任务可分支"); return; }
|
||||
try {
|
||||
const nt = await api<Task>(paths.taskFork(t.id), "POST", { message: msg.trim() });
|
||||
setForkMsg("分支任务已建:" + nt.id.slice(0, 8));
|
||||
onTasksReload();
|
||||
} catch (e: any) { setForkMsg("分支失败:" + e.message); }
|
||||
}, [onTasksReload]);
|
||||
|
||||
const postReview = useCallback(async (body: { target_type: string; branch?: string; sha?: string; instructions?: string }) => {
|
||||
const t = currentTaskRef.current;
|
||||
if (!t) return;
|
||||
try {
|
||||
await api(paths.taskReviews(t.id), "POST", body);
|
||||
setReviewMsg("评审已登记(requested)");
|
||||
await loadReviews(t.id);
|
||||
} catch (e: any) { setReviewMsg("登记评审需求失败:" + e.message); }
|
||||
}, [loadReviews]);
|
||||
|
||||
const submitFindings = useCallback((reviewId: string) => {
|
||||
const t = currentTaskRef.current;
|
||||
if (!t) return;
|
||||
const text = window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");
|
||||
if (text === null) return;
|
||||
void api(paths.taskReviewFindings(t.id, reviewId), "POST", { findings: text })
|
||||
.then(() => void loadReviews(t.id))
|
||||
.catch((e: any) => setReviewMsg("写 findings 失败:" + e.message));
|
||||
}, [loadReviews]);
|
||||
|
||||
const decideApproval = useCallback((approvalId: string, decision: string) => {
|
||||
const t = currentTaskRef.current;
|
||||
if (!t) return;
|
||||
const verb = decision === "approved" ? "approve" : "deny";
|
||||
void api(paths.taskApprovalDecision(t.id, approvalId, verb), "POST", {})
|
||||
.then(() => { setApprovalMsg("已记录" + (decision === "approved" ? "允许" : "拒绝") + "(当前版本只留回执,不拦截实时行为)"); void loadApprovals(t.id); })
|
||||
.catch((e: any) => setApprovalMsg("决定失败:" + e.message));
|
||||
}, [loadApprovals]);
|
||||
|
||||
const postPeer = useCallback(async (mode: string, target: string, text: string) => {
|
||||
const t = currentTaskRef.current;
|
||||
if (!t) return;
|
||||
if (!target.trim()) { setPeerMsg("目标任务 id 不能为空"); return; }
|
||||
if (!text.trim()) { setPeerMsg("消息正文不能为空"); return; }
|
||||
try {
|
||||
const d = await api<{ delivery?: string }>(paths.taskMessagesTo(target.trim()), "POST", { from_task_id: t.id, body: text.trim(), mode });
|
||||
setPeerMsg("已发送(" + d.delivery + ")");
|
||||
await loadPeerMessages(t.id);
|
||||
} catch (e: any) { setPeerMsg("发送失败:" + e.message); }
|
||||
}, [loadPeerMessages]);
|
||||
|
||||
const threadAction = useCallback((kind: string, name: string) => {
|
||||
const t = currentTaskRef.current;
|
||||
if (!t) return;
|
||||
if (!["completed", "failed", "interrupted"].includes(t.status)) {
|
||||
setThreadMsg("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");
|
||||
return;
|
||||
}
|
||||
const body: { name?: string } = {};
|
||||
if (kind === "rename") {
|
||||
if (!name.trim()) { setThreadMsg("新线程名不能为空"); return; }
|
||||
body.name = name.trim();
|
||||
}
|
||||
void api(paths.taskThreadAction(t.id, kind), "POST", body)
|
||||
.then((r: unknown) => { setThreadMsg(kind + " 已提交:" + JSON.stringify(r).slice(0, 300)); void loadThread(t.id); void loadThreads(t.id); })
|
||||
.catch((e: any) => setThreadMsg(kind + " 失败:" + e.message));
|
||||
}, [loadThread, loadThreads]);
|
||||
|
||||
const sendChatMessage = useCallback(async (text: string) => {
|
||||
const t = currentTaskRef.current;
|
||||
if (!t) return;
|
||||
const v = text.trim();
|
||||
if (!v) { setChatMsg("消息不能为空"); return; }
|
||||
try {
|
||||
if (TRACE_TERMINAL.includes(t.status) && t.status !== "merged") {
|
||||
// Message-only continue: all optional fields blank, so the
|
||||
// server keeps the task's original model/effort/approval.
|
||||
await api(paths.taskContinue(t.id), "POST", { message: v });
|
||||
setChatMsg("已重新排队,同一会话继续");
|
||||
onTasksReload();
|
||||
await showDetail(t.id);
|
||||
} else if (["queued", "running"].includes(t.status)) {
|
||||
await api(paths.taskFollowups(t.id), "POST", { body: v, mode: "direct" });
|
||||
setChatMsg("已直接发送");
|
||||
await loadFollowups(t.id);
|
||||
} else {
|
||||
setChatMsg("merged/merge-failed 任务不可继续,也不可发后续消息");
|
||||
}
|
||||
} catch (e: any) { setChatMsg("发送失败:" + e.message); }
|
||||
}, [showDetail, onTasksReload, loadFollowups]);
|
||||
|
||||
const downloadTrace = useCallback(async () => {
|
||||
const t = currentTaskRef.current;
|
||||
if (!t) { setTraceMsg("先选一个任务"); return; }
|
||||
try {
|
||||
const d = await api<{ entries: TraceEntry[]; note?: string; status?: string }>(paths.taskTrace(t.id));
|
||||
const blob = new Blob([JSON.stringify({ task_id: t.id, status: d.status, note: d.note || "", count: (d.entries || []).length, entries: d.entries || [] }, null, 2)], { type: "application/json" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "trace-" + t.id.slice(0, 8) + ".json";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
setTraceMsg("已下载完整链路 " + (d.entries || []).length + " 条");
|
||||
} catch (e: any) { setTraceMsg("下载失败:" + e.message); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => stopTrace(), [stopTrace]);
|
||||
|
||||
return {
|
||||
currentTask, detailBody, actionMsg, actionReasons, costBox, forkVisible, setForkVisible, forkMsg,
|
||||
coordChildren, agents, agentsNote, reviews, reviewMsg, reviewError, traceEntries, traceNote, traceDone,
|
||||
traceKind, setTraceKind, traceMsg, followups, approvals, approvalMsg, peerMessages, peerMsg,
|
||||
threadText, threadGateNote, threadMsg, threadList, chatMsg,
|
||||
showDetail, refreshDetailLists, doAction, taskCost, forkTask, postReview, submitFindings,
|
||||
decideApproval, postPeer, threadAction, sendChatMessage, downloadTrace,
|
||||
refreshReviews: () => void loadReviews(), refreshApprovals: () => void loadApprovals(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { api, shortId, uploadAttachments } from "../api/client";
|
||||
import { paths } from "../api/paths";
|
||||
import type { DispatchBody, Task } from "../api/types";
|
||||
|
||||
export function useTaskList() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [taskError, setTaskError] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [executorFilter, setExecutorFilter] = useState("");
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
const refreshTasks = useCallback(async (pid?: string, currentPid?: string) => {
|
||||
const id = pid !== undefined ? pid : currentPid;
|
||||
if (!id) { setTasks([]); return; }
|
||||
try {
|
||||
const d = await api<{ tasks: Task[] }>(paths.tasks(id));
|
||||
setTasks(d.tasks || []);
|
||||
setTaskError("");
|
||||
} catch (e: any) { setTaskError("加载失败:" + e.message); }
|
||||
}, []);
|
||||
|
||||
const filteredTasks = useMemo(() => {
|
||||
const q = searchText.trim().toLowerCase();
|
||||
return tasks.filter((t) => {
|
||||
if (statusFilter && t.status !== statusFilter) return false;
|
||||
if (executorFilter && (t.executor || "") !== executorFilter) return false;
|
||||
if (q && !((t.id || "") + " " + (t.prompt || "")).toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [tasks, statusFilter, executorFilter, searchText]);
|
||||
|
||||
const taskCountText = filteredTasks.length + " 个任务(共 " + tasks.length + " 个)";
|
||||
|
||||
const dispatch = useCallback(async (body: DispatchBody, atts: Array<{ path: string; file: File }>): Promise<string> => {
|
||||
const payload: DispatchBody = { ...body };
|
||||
if (atts.length) {
|
||||
payload.upload_id = await uploadAttachments(atts);
|
||||
}
|
||||
const t = await api<Task>(paths.tasksCreate, "POST", payload);
|
||||
return "任务已建:" + shortId(t.id);
|
||||
}, []);
|
||||
|
||||
return { tasks, taskError, statusFilter, setStatusFilter, executorFilter, setExecutorFilter, searchText, setSearchText, filteredTasks, taskCountText, refreshTasks, dispatch };
|
||||
}
|
||||
Reference in new issue
Block a user