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

@@ -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());
}
+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,
};
}
@@ -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 };
}