diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx index d775a15..d16d2e5 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx @@ -14,9 +14,9 @@ import { Waypoints, } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Sheet, @@ -26,6 +26,7 @@ import { } from "@/components/ui/sheet"; import { api } from "@/lib/api"; import type { CoverageAssetRef, CoverageAssetRefs, CoverageGraphEdge, CoverageGraphNode } from "@/lib/types"; +import { cn } from "@/lib/utils"; // 每个父节点下、同一类型的子节点默认展示的数量;超出折叠,"展示更多"每次再拉这么多。 const FOLD_LIMIT = 20; @@ -274,10 +275,18 @@ function RefList({ title, items }: { title: string; items: CoverageAssetRef[] })
{items.map((r) => ( -
- #{r.id} - {r.state && {r.state}} - {r.summary || "—"} +
+ #{r.id} + {r.state && {r.state}} + {r.summary || "—"} + {r.inherited && r.source_task_id && ( + + 来源 #{r.source_task_id} · 只读 + + )}
))}
diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx index f8f7190..37810d9 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx @@ -1,43 +1,32 @@ "use client"; import * as React from "react"; + import { ActivityIcon, AlertTriangleIcon, BugIcon, CheckIcon, ClockIcon, -<<<<<<< Updated upstream -======= CoinsIcon, ListChecksIcon, PencilIcon, PlusIcon, ->>>>>>> Stashed changes RefreshCwIcon, ShieldAlertIcon, ShieldCheckIcon, TargetIcon, -<<<<<<< Updated upstream -======= Trash2Icon, XIcon, ->>>>>>> Stashed changes } from "lucide-react"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Progress } from "@/components/ui/progress"; + import { StatusBadge } from "@/components/status-badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; +import { Progress } from "@/components/ui/progress"; import { api } from "@/lib/api"; -<<<<<<< Updated upstream -import type { Task, Stats, TaskNode, Finding } from "@/lib/types"; -======= import type { Finding, ModelTokenStat, @@ -64,6 +53,7 @@ function cacheHitRate(cacheRead: number, input: number): string { // 测试范围一条的显示值:域名 / 网段 / 公司。 function scopeValue(row: TaskScopeRow): string { + if (row.value) return row.value; if (row.domain) return row.domain; if (row.net) return row.net; if (row.company_id) return row.company_name || `企业 #${row.company_id}`; @@ -76,6 +66,8 @@ const SCOPE_KIND_LABELS: Record = { subdomain: "子域名", ip: "IP", cidr: "网段", + icp: "ICP", + keyword: "关键词", }; const SCOPE_SOURCE_LABELS: Record = { @@ -83,7 +75,6 @@ const SCOPE_SOURCE_LABELS: Record = { agent: "Agent", manual: "手动", }; ->>>>>>> Stashed changes function StatCard({ label, @@ -104,9 +95,7 @@ function StatCard({ {value} - {sub && ( - {sub} - )} + {sub && {sub}} ); } @@ -126,8 +115,6 @@ export function OverviewTab({ taskId }: { taskId: string }) { } | null>(null); // 正在重跑的意图 id(含 "__all__" 表示批量),用于禁用按钮 + 转圈。 const [rerunning, setRerunning] = React.useState>(new Set()); -<<<<<<< Updated upstream -======= // 测试范围列表 + 新增表单状态。 const [scope, setScope] = React.useState([]); const [scopeKind, setScopeKind] = React.useState("root_domain"); @@ -322,7 +309,6 @@ export function OverviewTab({ taskId }: { taskId: string }) { await loadScope(); // 删除失败:重新拉取还原 } }; ->>>>>>> Stashed changes const markRerun = (key: string, on: boolean) => setRerunning((prev) => { @@ -337,9 +323,7 @@ export function OverviewTab({ taskId }: { taskId: string }) { markRerun(id, true); try { await api.rerunIntent(taskId, id); - setIntents((prev) => - prev.map((i) => (i.id === id ? { ...i, state: "open" } : i)), - ); + setIntents((prev) => prev.map((i) => (i.id === id ? { ...i, state: "open" } : i))); } catch { // 失败忽略:下次轮询仍显示 blocked,用户可再点 } finally { @@ -352,9 +336,7 @@ export function OverviewTab({ taskId }: { taskId: string }) { markRerun("__all__", true); try { await api.rerunBlocked(taskId); - setIntents((prev) => - prev.map((i) => (i.state === "blocked" ? { ...i, state: "open" } : i)), - ); + setIntents((prev) => prev.map((i) => (i.state === "blocked" ? { ...i, state: "open" } : i))); } catch { // ignore } finally { @@ -367,13 +349,12 @@ export function OverviewTab({ taskId }: { taskId: string }) { const load = async () => { try { - const [tasksResp, statsResp, intentsResp, findingsResp] = - await Promise.all([ - api.tasks(), - api.stats(taskId), - api.intents(taskId), - api.findings(taskId), - ]); + const [tasksResp, statsResp, intentsResp, findingsResp] = await Promise.all([ + api.tasks(), + api.stats(taskId), + api.intents(taskId), + api.findings(taskId), + ]); if (cancelled) return; setTask(tasksResp.tasks.find((t) => t.id === taskId) ?? null); setStats(statsResp); @@ -386,16 +367,15 @@ export function OverviewTab({ taskId }: { taskId: string }) { .then((c) => { if (!cancelled) setCoverage(c); }) - .catch(() => {}); + .catch(() => { + // A transient coverage failure is retried by the next poll. + }); } catch { // transient errors are ignored; the next poll will retry } }; load(); -<<<<<<< Updated upstream - const timer = setInterval(load, 3000); -======= void loadScope(); void loadTokens(); void loadGoals(); @@ -406,24 +386,29 @@ export function OverviewTab({ taskId }: { taskId: string }) { void loadGoals(); void loadConstraints(); }, 3000); ->>>>>>> Stashed changes return () => { cancelled = true; clearInterval(timer); }; -<<<<<<< Updated upstream - }, [taskId]); -======= }, [taskId, loadScope, loadTokens, loadGoals, loadConstraints]); ->>>>>>> Stashed changes const running = intents.filter((i) => i.state === "running"); const open = intents.filter((i) => i.state === "open"); const blocked = intents.filter((i) => i.state === "blocked"); const taskFindings = findings.filter((f) => f.task_id === taskId); - const goalsPct = task?.goals_total - ? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100) - : 0; + const goalsPct = task?.goals_total ? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100) : 0; + // token 合计(跨全部模型),用于卡片头部总览。 + const tokenTotals = modelTokens.reduce( + (acc, m) => { + acc.input += m.input_tokens; + acc.output += m.output_tokens; + acc.cacheRead += m.cache_read_tokens; + acc.cacheWrite += m.cache_write_tokens; + acc.calls += m.calls; + return acc; + }, + { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, calls: 0 }, + ); return (
@@ -437,15 +422,11 @@ export function OverviewTab({ taskId }: { taskId: string }) {
描述
-

- {task?.description?.trim() || "—"} -

+

{task?.description?.trim() || "—"}

目标
-

- {task?.goal?.trim() || "—"} -

+

{task?.goal?.trim() || "—"}

@@ -604,7 +585,12 @@ export function OverviewTab({ taskId }: { taskId: string }) { }} disabled={conBusy} /> - {conErr && {conErr}} @@ -727,6 +713,163 @@ export function OverviewTab({ taskId }: { taskId: string }) { )} + {/* LLM Token 用量:按模型分组,数据来自 llm_records(需开启 LLM 录制)。 */} + + + + LLM Token 用量 + + (按模型统计{tokenTotals.calls > 0 ? `,共 ${tokenTotals.calls} 次调用` : ""}) + + + + + {modelTokens.length > 0 ? ( + <> + {/* 合计总览 */} +
+ + 输入 + {fmtTokens(tokenTotals.input)} + + + 输出 + {fmtTokens(tokenTotals.output)} + + + 缓存读 + {fmtTokens(tokenTotals.cacheRead)} + + + 缓存命中率 + + {cacheHitRate(tokenTotals.cacheRead, tokenTotals.input)} + + +
+ {/* 按模型明细表 */} +
+ + + + + + + + + + + + + {modelTokens.map((m) => ( + + + + + + + + + ))} + +
模型调用输入输出缓存读命中率
+ {m.model} + {m.calls}{fmtTokens(m.input_tokens)}{fmtTokens(m.output_tokens)}{fmtTokens(m.cache_read_tokens)} + {cacheHitRate(m.cache_read_tokens, m.input_tokens)} +
+
+ + ) : ( +

暂无 LLM 用量(任务尚未产生调用,或记录仍在写入)。

+ )} +
+
+ {/* 测试范围:覆盖度分母 + 授权边界,可手动增删。 */} + + + + 测试范围 + + (覆盖度分母 + 授权边界,共 {scope.length} 条) + + + + + {/* 新增表单 */} +
+ setScopeKind(e.target.value as TaskScopeRow["kind"])} + > + 根域名 + 子域名 + IP + 网段 + ICP + 关键词 + 公司 + + setScopeValueInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void addScope(); + }} + disabled={scopeBusy} + /> + + {scopeErr && {scopeErr}} +
+ {/* 范围列表 */} + {scope.length > 0 ? ( +
+ {scope.map((row) => ( +
+ + {SCOPE_KIND_LABELS[row.kind]} + + {scopeValue(row)} + {SCOPE_SOURCE_LABELS[row.source]} + {row.task_id.toString() === taskId ? ( + + ) : ( + 继承 + )} +
+ ))} +
+ ) : ( +

暂无测试范围,添加后可作为资产覆盖度的分母。

+ )} +
+
{/* Heartbeat */} @@ -746,17 +889,13 @@ export function OverviewTab({ taskId }: { taskId: string }) {
运行中 Worker
-
- {running.length} -
+
{running.length}
最近活动
- {task?.last_activity - ? new Date(task.last_activity).toLocaleTimeString("zh-CN") - : "—"} + {task?.last_activity ? new Date(task.last_activity).toLocaleTimeString("zh-CN") : "—"}
@@ -792,9 +931,7 @@ export function OverviewTab({ taskId }: { taskId: string }) { {i.payload}
))} - {running.length === 0 && ( -

暂无进行中意图

- )} + {running.length === 0 &&

暂无进行中意图

} @@ -806,27 +943,19 @@ export function OverviewTab({ taskId }: { taskId: string }) {
-
- {taskFindings.length} -
+
{taskFindings.length}
确认漏洞
-
- {running.length} -
+
{running.length}
执行中
-
- {open.length} -
+
{open.length}
frontier 待领
-
- {blocked.length} -
+
{blocked.length}
被拦意图
@@ -845,9 +974,7 @@ export function OverviewTab({ taskId }: { taskId: string }) { {f.summary}
))} - {taskFindings.length === 0 && ( -

暂无发现

- )} + {taskFindings.length === 0 &&

暂无发现

} @@ -859,19 +986,10 @@ export function OverviewTab({ taskId }: { taskId: string }) { 被拦/出错意图 - - (共 {blocked.length} 条,可重跑) - + (共 {blocked.length} 条,可重跑) - @@ -887,9 +1005,7 @@ export function OverviewTab({ taskId }: { taskId: string }) { disabled={rerunning.has(i.id)} onClick={() => void rerunOne(i.id)} > - + 重跑 @@ -905,24 +1021,9 @@ export function OverviewTab({ taskId }: { taskId: string }) { {/* Stat cards */}
- - - + + +
); diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx index 5846057..ad385f9 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx @@ -1,54 +1,67 @@ "use client"; import * as React from "react"; + import { - RadioIcon, + ArrowUpIcon, BrainIcon, - UserIcon, - Loader2Icon, - ZapOffIcon, - SendIcon, - SquareIcon, - ClockIcon, CircleCheckIcon, - CircleXIcon, CircleSlashIcon, - ShieldAlertIcon, - WifiOffIcon, - RotateCwIcon, + CircleXIcon, + ClockIcon, + HistoryIcon, + Loader2Icon, PaperclipIcon, + PauseIcon, + PlayIcon, + RadioIcon, + RotateCwIcon, + ShieldAlertIcon, + SquareIcon, + Trash2Icon, + UserIcon, + WifiOffIcon, XIcon, + ZapOffIcon, } from "lucide-react"; import { toast } from "sonner"; -import { cn } from "@/lib/utils"; -import { Transcript } from "@/components/transcript"; + import { TodoPopover } from "@/components/todo-popover"; -import { Button } from "@/components/ui/button"; -<<<<<<< Updated upstream -import { Input } from "@/components/ui/input"; -======= -import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group"; -import { Textarea } from "@/components/ui/textarea"; ->>>>>>> Stashed changes -import { ScrollArea } from "@/components/ui/scroll-area"; +import { Transcript } from "@/components/transcript"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Textarea } from "@/components/ui/textarea"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { api, sseUrl } from "@/lib/api"; import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode"; import { MOCK } from "@/lib/mock/enabled"; +import { taskAssetSourceLabel, taskAssetTypeLabel } from "@/lib/task-assets"; import type { Activity, ChatAttachment, + IntentAsset, InterceptApprovalRow, Session, SessionStatus, + SessionTokenUsage, + TaskLLMResolution, + TaskLLMResolutions, TaskNode, TokenTotal, } from "@/lib/types"; +import { cn } from "@/lib/utils"; // resolutionLabel names the LLM a session runs on. The badge shows this alone and // keeps the model id in its tooltip. Env-backed configs can arrive without a name, @@ -76,6 +89,7 @@ function fmtBytes(n: number): string { // so refresh / tab-switch / sleep / reconnect never drop the newest records. const PAGE = 200; // history page size +const SYSTEM_SCAN_PAGE = 500; // generic activity pages scanned to recover sparse system audit events const MAX_KEEP = 4000; // per-session in-memory cap; older pages re-fetched on scroll-up const STREAM_WINDOW_MS = 5000; // "live" = activity seen within this window @@ -111,6 +125,7 @@ function emptyState(): SessionState { // sessionKeyOf routes an activity to its stable session key. worker="planner" covers // BOTH the Goal Agent's round-0 decomposition and the Planner (single Plan session). function sessionKeyOf(a: Activity): string { + if (a.worker === "system" || a.kind === "llm_switch" || a.kind === "llm_failover") return "system"; if (a.worker === "mainagent") return "main"; if (a.worker === "planner") return "plan"; if (a.intent_id) return `intent:${a.intent_id}`; @@ -135,6 +150,8 @@ function statusIcon(status: SessionStatus) { switch (status) { case "running": // 执行中 return ; + case "paused": + return ; case "pending": // 待领取(open intent) return ; case "done": // 完成 @@ -150,11 +167,41 @@ function statusIcon(status: SessionStatus) { // fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M). function fmtTokens(n: number): string { - if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M"; - if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k"; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`; + if (n >= 1000) return `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k`; return String(n); } +const TokenMetrics = React.forwardRef< + HTMLSpanElement, + React.ComponentPropsWithoutRef<"span"> & { + input: number; + cache: number; + output: number; + labels?: "short" | "long"; + } +>(({ input, cache, output, labels = "short", className, ...props }, ref) => { + const names = labels === "short" ? ["入", "缓", "出"] : ["input", "cache", "output"]; + const values = [input, cache, output]; + return ( + + {values.map((value, index) => ( + + {names[index]} + + {fmtTokens(value)} + + + ))} + + ); +}); +TokenMetrics.displayName = "TokenMetrics"; + // fmtDuration renders an elapsed milliseconds span compactly (90s → 1m30s). function fmtDuration(ms: number): string { const s = Math.floor(ms / 1000); @@ -169,6 +216,7 @@ const roleMeta = { mainagent: { label: "主 Agent", icon: UserIcon }, planner: { label: "规划 Planner", icon: BrainIcon }, worker: { label: "Workers", icon: RadioIcon }, + system: { label: "系统审计", icon: HistoryIcon }, } as const; // The main-agent session is the interactive entry point of this tab and has no @@ -198,10 +246,24 @@ const PLANNER_SESSION: Session = { last_activity: "", }; +// LLM provider switches are task-level audit events rather than agent output. The +// history endpoint has no "system" filter, so this fixed session is populated by +// scanning the generic incremental activity endpoint and then tailed by task SSE. +const SYSTEM_ID = "s-system"; +const SYSTEM_SESSION: Session = { + id: SYSTEM_ID, + role: "system", + title: "系统事件 · LLM 故障转移", + status: "done", + live: false, + last_activity: "", +}; + // keyForSession maps a UI Session → its stable store key (main | plan | intent:). function keyForSession(s: Session): string { if (s.role === "mainagent") return "main"; if (s.role === "planner") return "plan"; + if (s.role === "system") return "system"; return `intent:${s.intent_id}`; } @@ -216,6 +278,8 @@ function intentStatus(state: string): SessionStatus { return "exhausted"; case "stopped": return "stopped"; + case "paused": + return "paused"; case "open": // 待领取,区别于执行中 return "pending"; default: // running @@ -233,9 +297,11 @@ function intentToSession(n: TaskNode): Session { role: "worker", title: label || `Intent ${n.id}`, status: state, - live: state === "running", + live: !n.inherited && state === "running", last_activity: n.ts, intent_id: n.id, + source_task_id: n.source_task_id, + inherited: n.inherited, }; } @@ -246,14 +312,11 @@ function SessionItem({ hasPending, unread, onClick, -<<<<<<< Updated upstream -======= onPause, onResume, onCancel, controlling, deleted, ->>>>>>> Stashed changes }: { s: Session; active: boolean; @@ -261,14 +324,11 @@ function SessionItem({ hasPending?: boolean; unread?: number; onClick: () => void; -<<<<<<< Updated upstream -======= onPause?: () => void; onResume?: () => void; onCancel?: () => void; controlling?: boolean; deleted?: boolean; ->>>>>>> Stashed changes }) { const icon = deleted ? ( @@ -278,21 +338,14 @@ function SessionItem({ ) : null; + const controllable = s.role === "worker" && !s.inherited && (s.status === "running" || s.status === "paused"); return ( - ->>>>>>> Stashed changes )} - {displayTitle} - {hasPending && ( - - )} - {!active && unread ? ( - - {unread > 99 ? "99+" : unread} - - ) : null} - {s.live && ( - - - 实时 - - )} - + + ); +} + +function WorkerAssetBadge({ assets }: { assets: IntentAsset[] }) { + const first = assets[0]; + const firstLabel = first.label.trim() ? first.label : `#${first.asset_id}`; + return ( + + + + 当前资产:{firstLabel} + {assets.length > 1 && +{assets.length - 1}} + + + +
+ {assets.map((asset) => ( +
+
+ {asset.label.trim() ? asset.label : `#${asset.asset_id}`} +
+
+ {taskAssetTypeLabel(asset.type)} · {taskAssetSourceLabel(asset.source)} + {asset.inherited ? ` · 来源任务 #${asset.source_task_id}` : ""} +
+
{asset.source_summary}
+
+ ))} +
+
+
); } @@ -397,21 +467,19 @@ export function SessionsTab({ taskId }: { taskId: string }) { const [store, setStore] = React.useState({}); // Worker sessions derived from exploration intents (paged past the old 300 cap). const [intents, setIntents] = React.useState([]); + const [intentAssets, setIntentAssets] = React.useState([]); const [olderIntents, setOlderIntents] = React.useState([]); - const [intentsHasMore, setIntentsHasMore] = React.useState(false); + const [firstIntentsHasMore, setFirstIntentsHasMore] = React.useState(false); + const [olderIntentsHasMore, setOlderIntentsHasMore] = React.useState(false); + const [hasLoadedOlderIntentsPage, setHasLoadedOlderIntentsPage] = React.useState(false); const [loadingOlderIntents, setLoadingOlderIntents] = React.useState(false); - // Local-only chat messages overlaid onto the main-agent transcript. - const [chatExtra, setChatExtra] = React.useState([]); const [input, setInput] = React.useState(""); const [sending, setSending] = React.useState(false); const [stopping, setStopping] = React.useState(false); -<<<<<<< Updated upstream -======= const [mainChatRunning, setMainChatRunning] = React.useState(null); const [controllingIntent, setControllingIntent] = React.useState(null); const [cancelIntent, setCancelIntent] = React.useState(null); const [cancelReason, setCancelReason] = React.useState(""); ->>>>>>> Stashed changes // 方式1 文件上传:选好的附件(已落到任务工作目录 uploads/),随下条消息一起发。 const [attachments, setAttachments] = React.useState([]); const [uploading, setUploading] = React.useState(false); @@ -424,14 +492,12 @@ export function SessionsTab({ taskId }: { taskId: string }) { const r = await api.chatUpload("task", taskId, Array.from(files)); setAttachments((prev) => [...prev, ...r.attachments]); } catch (e) { - toast.error("上传失败:" + (e as Error).message); + toast.error(`上传失败:${(e as Error).message}`); } finally { setUploading(false); if (fileInputRef.current) fileInputRef.current.value = ""; } } -<<<<<<< Updated upstream -======= const patchIntentState = React.useCallback((intentId: string, state?: string) => { const patch = (rows: TaskNode[]) => @@ -473,12 +539,13 @@ export function SessionsTab({ taskId }: { taskId: string }) { }, [controllingIntent, patchIntentState, taskId], ); ->>>>>>> Stashed changes // SSE connection state — surfaced so a dropped realtime link is visible, never // silently shown as "no messages". const [sseLive, setSseLive] = React.useState(false); // Whole-task token total (all agents), polled from the backend aggregate. const [taskTokens, setTaskTokens] = React.useState(null); + const [sessionTokens, setSessionTokens] = React.useState>({}); + const [llmResolutions, setLLMResolutions] = React.useState(null); // Pending intercept requests for this task — used to show warning icons on sessions. const [pendingIntercepts, setPendingIntercepts] = React.useState([]); @@ -487,6 +554,9 @@ export function SessionsTab({ taskId }: { taskId: string }) { const esRef = React.useRef(null); const activeKeyRef = React.useRef("main"); // current session key (for SSE dispatch/unread) const atBottomRef = React.useRef(true); // transcript pinned to bottom? + const llmToastSeqRef = React.useRef>(new Set()); + const chatStatusRequestRef = React.useRef(0); + const firstIntentsRef = React.useRef([]); // Per-key request token: a stale response for a key is ignored (guards fast // latest/older interleaving). Writes are ALWAYS keyed, so a late response can only // touch its own session cache — never the currently-viewed one (see §7.5). @@ -497,12 +567,9 @@ export function SessionsTab({ taskId }: { taskId: string }) { const loadingKeysRef = React.useRef>(new Set()); // ── store helpers ────────────────────────────────────────────────────────────── - const patchStore = React.useCallback( - (key: string, fn: (s: SessionState) => SessionState) => { - setStore((prev) => ({ ...prev, [key]: fn(prev[key] ?? emptyState()) })); - }, - [], - ); + const patchStore = React.useCallback((key: string, fn: (s: SessionState) => SessionState) => { + setStore((prev) => ({ ...prev, [key]: fn(prev[key] ?? emptyState()) })); + }, []); // Load a session's LATEST page (before=0) on first open. Keyed + request-token // guarded so a switch away can't corrupt the view. @@ -514,6 +581,50 @@ export function SessionsTab({ taskId }: { taskId: string }) { const token = (reqTokenRef.current[key] ?? 0) + 1; reqTokenRef.current[key] = token; patchStore(key, (s) => ({ ...s, loading: true, error: undefined })); + + if (key === "system") { + void (async () => { + let since = 0; + let systemItems: Activity[] = []; + for (;;) { + const page = await api.activity(taskId, { since, limit: SYSTEM_SCAN_PAGE }); + if (reqTokenRef.current[key] !== token) return; + systemItems = mergeBySeq( + systemItems, + page.items.filter( + (item) => item.worker === "system" || item.kind === "llm_switch" || item.kind === "llm_failover", + ), + ); + if (page.items.length < SYSTEM_SCAN_PAGE || page.cursor <= since) break; + since = page.cursor; + } + patchStore(key, (s) => { + const items = mergeBySeq(systemItems, s.items); + return { + ...s, + items, + loaded: true, + loading: false, + hasMore: false, + earliestSeq: items.length ? items[0].seq : 0, + unread: 0, + lastTs: items.length ? items[items.length - 1].ts : s.lastTs, + error: undefined, + }; + }); + })() + .catch((error) => { + if (reqTokenRef.current[key] !== token) return; + patchStore(key, (s) => ({ + ...s, + loading: false, + error: (error as Error).message || "加载失败", + })); + }) + .finally(() => loadingKeysRef.current.delete(key)); + return; + } + api .activityHistory(taskId, key, 0, PAGE) .then((r) => { @@ -583,10 +694,14 @@ export function SessionsTab({ taskId }: { taskId: string }) { api .tokenStats(taskId) .then((r) => { - if (alive) setTaskTokens(r.total); + if (!alive) return; + setTaskTokens(r.total); + setSessionTokens(Object.fromEntries(r.sessions.map((item) => [item.session, item]))); }) - .catch(() => {}); - load(); + .catch(() => { + // Polling is best-effort; the next interval retries automatically. + }); + void load(); const t = setInterval(load, 5000); return () => { alive = false; @@ -594,15 +709,65 @@ export function SessionsTab({ taskId }: { taskId: string }) { }; }, [taskId]); + React.useEffect(() => { + let alive = true; + setMainChatRunning(null); + const load = () => { + const request = ++chatStatusRequestRef.current; + return api + .chatStatus(taskId) + .then(({ running }) => { + if (alive && chatStatusRequestRef.current === request) setMainChatRunning(running); + }) + .catch(() => { + // Keep the last authoritative value. Before the first success, recent + // activity remains a conservative fallback. + }); + }; + void load(); + const timer = setInterval(load, 2000); + return () => { + alive = false; + clearInterval(timer); + }; + }, [taskId]); + React.useEffect(() => { let alive = true; const load = () => - api.interceptTask(taskId) - .then((rows) => { if (alive) setPendingIntercepts(rows.filter((r) => r.status === "pending")); }) - .catch(() => {}); - load(); + api + .taskLLMResolution(taskId) + .then((value) => { + if (alive) setLLMResolutions(value); + }) + .catch(() => { + // Polling is best-effort; the next interval retries automatically. + }); + void load(); + const timer = setInterval(load, 10_000); + return () => { + alive = false; + clearInterval(timer); + }; + }, [taskId]); + + React.useEffect(() => { + let alive = true; + const load = () => + api + .interceptTask(taskId) + .then((rows) => { + if (alive) setPendingIntercepts(rows.filter((r) => r.status === "pending")); + }) + .catch(() => { + // Polling is best-effort; the next interval retries automatically. + }); + void load(); const t = setInterval(load, 5000); - return () => { alive = false; clearInterval(t); }; + return () => { + alive = false; + clearInterval(t); + }; }, [taskId]); // Re-render on a timer so "streaming" liveness recomputes as activity goes stale. @@ -620,10 +785,19 @@ export function SessionsTab({ taskId }: { taskId: string }) { // ID) resumes from the DB, so a dropped realtime link self-heals; seq-merge dedups. React.useEffect(() => { setStore({}); - setChatExtra([]); + setIntents([]); + setIntentAssets([]); + setOlderIntents([]); + setFirstIntentsHasMore(false); + setOlderIntentsHasMore(false); + setHasLoadedOlderIntentsPage(false); + setTaskTokens(null); + setSessionTokens({}); + setLLMResolutions(null); setSseLive(false); setActiveId(MAIN_ID); // a stale worker id from the previous task must not leak in snapshotRef.current = 0; + llmToastSeqRef.current = new Set(); reqTokenRef.current = {}; // Reserve "main" so the active-session effect (which also fires for main on mount) // won't double-load it and pre-empt the SSE opened here. @@ -651,9 +825,15 @@ export function SessionsTab({ taskId }: { taskId: string }) { st.lastTs = st.items.length ? st.items[st.items.length - 1].ts : ""; } buckets.main ??= { ...emptyState(), loaded: true }; + buckets.system ??= { ...emptyState(), loaded: true }; setStore(buckets); }) - .catch(() => setStore({ main: { ...emptyState(), loaded: true } })); + .catch(() => + setStore({ + main: { ...emptyState(), loaded: true }, + system: { ...emptyState(), loaded: true }, + }), + ); return () => { alive = false; }; @@ -693,15 +873,40 @@ export function SessionsTab({ taskId }: { taskId: string }) { } catch { return; // ignore malformed frame } + if ((a.kind === "llm_switch" || a.kind === "llm_failover") && !llmToastSeqRef.current.has(a.seq)) { + llmToastSeqRef.current.add(a.seq); + const transition = a.metadata?.llm_transition; + if (transition?.mode === "exhausted" || a.is_error) { + toast.error(a.summary, { id: `task-${taskId}-llm-${a.seq}` }); + } else if (transition?.mode === "automatic") { + toast.success(a.summary, { id: `task-${taskId}-llm-${a.seq}` }); + } else { + toast.info(a.summary, { id: `task-${taskId}-llm-${a.seq}` }); + } + void api + .taskLLMResolution(taskId) + .then((value) => { + if (alive) setLLMResolutions(value); + }) + .catch(() => { + // The periodic resolver poll will retry if this event-triggered refresh fails. + }); + } const k = sessionKeyOf(a); setStore((prev) => { const cur = prev[k] ?? emptyState(); const activeK = activeKeyRef.current; // Merge into sessions that are loaded, actively loading, or the current // view (so returning is instant + a frame that lands mid-load isn't lost). - // A cold, inactive session only tracks liveness + unread until it's opened. + // A cold, inactive session also keeps a tiny accounting tail so the + // sidebar can add the latest unfinished usage without loading history. if (!cur.loaded && !cur.loading && k !== activeK) { - return { ...prev, [k]: { ...cur, lastTs: a.ts, unread: cur.unread + 1 } }; + const accountingItems = + a.kind === "usage" || a.kind === "result" ? mergeBySeq(cur.items, [a]).slice(-4) : cur.items; + return { + ...prev, + [k]: { ...cur, items: accountingItems, lastTs: a.ts, unread: cur.unread + 1 }, + }; } let items = mergeBySeq(cur.items, [a]); // Memory bound: trim oldest when over cap (older re-fetched on scroll-up), @@ -730,22 +935,60 @@ export function SessionsTab({ taskId }: { taskId: string }) { esRef.current?.close(); esRef.current = null; }; - // eslint-disable-next-line react-hooks/exhaustive-deps + }, [taskId, patchStore]); + + React.useEffect(() => { + let active = true; + const load = () => + api + .taskIntentAssets(taskId) + .then((assets) => { + if (active) setIntentAssets(assets); + }) + .catch(() => { + // The next poll retries; Worker controls and transcripts remain available. + }); + void load(); + const timer = setInterval(load, 5000); + return () => { + active = false; + clearInterval(timer); + }; }, [taskId]); // ── worker (intent) session list — paged, poll first page lightly ─────────────── React.useEffect(() => { let active = true; + firstIntentsRef.current = []; + setIntents([]); + setOlderIntents([]); + setFirstIntentsHasMore(false); + setOlderIntentsHasMore(false); const load = () => api .intentsPage(taskId, 0, 300) .then((r) => { if (!active) return; + const freshIds = new Set(r.items.map((item) => item.id)); + const oldestFreshId = r.items.reduce((minimum, item) => Math.min(minimum, Number(item.id)), Infinity); + const displaced = r.hasMore + ? firstIntentsRef.current.filter((item) => !freshIds.has(item.id) && Number(item.id) < oldestFreshId) + : []; + if (displaced.length > 0) { + setOlderIntents((previous) => { + const byId = new Map(previous.map((item) => [item.id, item])); + for (const item of displaced) byId.set(item.id, item); + return [...byId.values()]; + }); + } + firstIntentsRef.current = r.items; setIntents(r.items); - setIntentsHasMore(r.hasMore); + setFirstIntentsHasMore(r.hasMore); }) - .catch(() => {}); - load(); + .catch(() => { + // Polling is best-effort; the next interval retries automatically. + }); + void load(); const t = setInterval(load, 5000); return () => { active = false; @@ -766,9 +1009,12 @@ export function SessionsTab({ taskId }: { taskId: string }) { const seen = new Set([...intents, ...prev].map((n) => n.id)); return [...prev, ...r.items.filter((n) => !seen.has(n.id))]; }); - setIntentsHasMore(r.hasMore); + setOlderIntentsHasMore(r.hasMore); + setHasLoadedOlderIntentsPage(true); + }) + .catch(() => { + // A later manual retry can fetch this page again. }) - .catch(() => {}) .finally(() => setLoadingOlderIntents(false)); }, [taskId, intents, olderIntents, loadingOlderIntents]); @@ -779,11 +1025,19 @@ export function SessionsTab({ taskId }: { taskId: string }) { for (const n of intents) byId.set(n.id, n); // fresh poll wins over older snapshot return [...byId.values()].sort((a, b) => Number(b.id) - Number(a.id)); }, [intents, olderIntents]); + const intentAssetsByID = React.useMemo(() => { + const grouped = new Map(); + for (const asset of intentAssets) { + const key = String(asset.intent_id); + const current = grouped.get(key); + if (current) current.push(asset); + else grouped.set(key, [asset]); + } + return grouped; + }, [intentAssets]); - const workerSessions = React.useMemo( - () => allIntents.map(intentToSession), - [allIntents], - ); + const workerSessions = React.useMemo(() => allIntents.map(intentToSession), [allIntents]); + const intentsHasMore = hasLoadedOlderIntentsPage ? olderIntentsHasMore : firstIntentsHasMore; // For each worker session (intent), derive the display title from the intent // payload summary. Also store the full TaskNode for the hover-JSON tooltip. @@ -817,9 +1071,10 @@ export function SessionsTab({ taskId }: { taskId: string }) { // Worker agent_name format: "work#N · #intentID". Main/planner match by role key. const hasPendingForSession = React.useCallback( (s: Session): boolean => { + if (s.inherited) return false; if (!pendingIntercepts.length) return false; if (s.role === "mainagent") return pendingIntercepts.some((r) => r.agent_name === "mainagent"); - if (s.role === "planner") return pendingIntercepts.some((r) => r.agent_name === "planner"); + if (s.role === "planner") return pendingIntercepts.some((r) => r.agent_name === "planner"); // Worker: extract intent ID from "work#N · #" return pendingIntercepts.some((r) => { const m = r.agent_name.match(/·\s*#(\d+)$/); @@ -839,7 +1094,7 @@ export function SessionsTab({ taskId }: { taskId: string }) { }, [store], ); - const mainLive = recentLive("main"); + const mainLive = sending || (mainChatRunning ?? recentLive("main")); const plannerLive = recentLive("plan"); const sessions = React.useMemo( @@ -847,6 +1102,7 @@ export function SessionsTab({ taskId }: { taskId: string }) { { ...MAIN_SESSION, live: mainLive }, { ...PLANNER_SESSION, live: plannerLive }, ...workerSessions, + SYSTEM_SESSION, ], [workerSessions, mainLive, plannerLive], ); @@ -855,16 +1111,19 @@ export function SessionsTab({ taskId }: { taskId: string }) { mainagent: sessions.filter((s) => s.role === "mainagent"), planner: sessions.filter((s) => s.role === "planner"), worker: sessions.filter((s) => s.role === "worker"), + system: sessions.filter((s) => s.role === "system"), }; const active = sessions.find((s) => s.id === activeId) ?? MAIN_SESSION; const isMain = active.role === "mainagent"; const isPlanner = active.role === "planner"; + const isSystem = active.role === "system"; const activeKey = keyForSession(active); const activeState = store[activeKey]; // Keep the SSE dispatcher's notion of the active session current, and lazily load // + clear unread whenever the active session changes. + // biome-ignore lint/correctness/useExhaustiveDependencies: cache updates must not reactivate the current session. React.useEffect(() => { activeKeyRef.current = activeKey; const st = store[activeKey]; @@ -873,7 +1132,6 @@ export function SessionsTab({ taskId }: { taskId: string }) { } else if (st.unread) { patchStore(activeKey, (s) => ({ ...s, unread: 0 })); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeKey]); // Main agent is the human↔orchestrator CONSOLE: only the conversation (user msgs + @@ -881,14 +1139,12 @@ export function SessionsTab({ taskId }: { taskId: string }) { // worker session shows only its intent's activity, led by the intent objective. const activity = React.useMemo(() => { const items = activeState?.items ?? []; - if (isMain) { - // chatExtra is an optimistic local echo — drop any entry the persisted (SSE) - // copy already covers so messages don't show twice after the backend round-trips. - const seen = new Set(items.map((a) => `${a.kind} ${a.summary}`)); - const pending = chatExtra.filter((a) => !seen.has(`${a.kind} ${a.summary}`)); - return [...items, ...pending].sort((a, b) => a.seq - b.seq); - } - if (isPlanner) return items; + // The main-agent console renders purely from server data: the human turn is + // persisted+broadcast by the backend BEFORE it returns, so it arrives over the + // same SSE stream (worker="mainagent") as every agent step — no client-side + // optimistic echo, hence no fabricated seq that could collide with real DB ids. + if (isMain) return items; + if (isPlanner || isSystem) return items; // Worker session: the intent leads the transcript as a right-aligned "user"-style // message (the task handed to this worker), followed by its execution steps. const intentTitle = sessionMeta.get(active.id)?.title ?? active.title; @@ -898,9 +1154,22 @@ export function SessionsTab({ taskId }: { taskId: string }) { ts: active.last_activity || "", kind: "intent", // LLM-generated objective — rendered as a distinct (non-human) bubble summary: intentTitle, + source_task_id: active.source_task_id, + inherited: active.inherited, }; return [intentMsg, ...items]; - }, [isMain, isPlanner, activeState, chatExtra, active.id, active.title, active.last_activity, sessionMeta]); + }, [ + isMain, + isPlanner, + isSystem, + activeState, + active.id, + active.title, + active.last_activity, + active.source_task_id, + active.inherited, + sessionMeta, + ]); // seq of this session's most-recent TodoWrite call — for the Todo popover. const latestTodoSeq = React.useMemo(() => { @@ -911,34 +1180,52 @@ export function SessionsTab({ taskId }: { taskId: string }) { return null; }, [activity]); - // Per-session token total, live. Sum COMPLETED runs' results + the current run's - // latest usage. Computed over the loaded pages (the running/just-finished run whose - // totals matter sits on the latest page). (activity is seq-sorted.) - const tokenTotal = React.useMemo(() => { - let i = 0, o = 0, cr = 0, cw = 0; // completed - let li = 0, lo = 0, lcr = 0, lcw = 0; // live in-progress - for (const a of activity) { - if (a.kind === "result") { - i += a.input_tokens ?? 0; - o += a.output_tokens ?? 0; - cr += a.cache_read_tokens ?? 0; - cw += a.cache_write_tokens ?? 0; - li = lo = lcr = lcw = 0; // run finished → drop its live partial - } else if (a.kind === "usage") { - li = a.input_tokens ?? 0; - lo = a.output_tokens ?? 0; - lcr = a.cache_read_tokens ?? 0; - lcw = a.cache_write_tokens ?? 0; + // Persisted result totals come from the backend's full activity history. Only + // the latest unfinished usage frame is added client-side, so partial history + // pages cannot undercount completed runs or double-count the active one. + const tokenForSession = React.useCallback( + (key: string): TokenTotal => { + const saved = sessionTokens[key]; + const total: TokenTotal = { + input_tokens: saved?.input_tokens ?? 0, + output_tokens: saved?.output_tokens ?? 0, + cache_read_tokens: saved?.cache_read_tokens ?? 0, + cache_write_tokens: saved?.cache_write_tokens ?? 0, + }; + const items = store[key]?.items ?? []; + for (let index = items.length - 1; index >= 0; index--) { + const item = items[index]; + if (item.kind === "result") break; + if (item.kind !== "usage") continue; + total.input_tokens += item.input_tokens ?? 0; + total.output_tokens += item.output_tokens ?? 0; + total.cache_read_tokens += item.cache_read_tokens ?? 0; + total.cache_write_tokens += item.cache_write_tokens ?? 0; + break; } - } - const I = i + li, O = o + lo, CR = cr + lcr, CW = cw + lcw; - return { i: I, o: O, cr: CR, cw: CW, any: I + O + CR + CW > 0 }; - }, [activity]); + return total; + }, + [sessionTokens, store], + ); + + const activeTokens = tokenForSession(activeKey); + const tokenTotal = { + i: activeTokens.input_tokens, + o: activeTokens.output_tokens, + cr: activeTokens.cache_read_tokens, + any: + activeTokens.input_tokens + + activeTokens.output_tokens + + activeTokens.cache_read_tokens + + activeTokens.cache_write_tokens > + 0, + }; // Run duration = span from this session's first step to its last (for a live // session, "now" so it ticks up — the 1.5s setTick above re-renders it). const runDuration = React.useMemo(() => { - let min = Infinity, max = 0; + let min = Infinity, + max = 0; for (const a of activity) { const t = Date.parse(a.ts); if (!Number.isFinite(t)) continue; @@ -953,11 +1240,10 @@ export function SessionsTab({ taskId }: { taskId: string }) { // ---- transcript auto-scroll (open → bottom; stick to bottom unless scrolled up) ---- const contentRef = React.useRef(null); const viewport = React.useCallback( - () => - (contentRef.current?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null) ?? - null, + () => (contentRef.current?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null) ?? null, [], ); + // biome-ignore lint/correctness/useExhaustiveDependencies: activeId intentionally rebinds the scroll listener. React.useEffect(() => { const vp = viewport(); if (!vp) return; @@ -969,6 +1255,7 @@ export function SessionsTab({ taskId }: { taskId: string }) { return () => vp.removeEventListener("scroll", onScroll); }, [viewport, activeId, loadEarlier]); // open/switch a session → jump to the latest (bottom) + // biome-ignore lint/correctness/useExhaustiveDependencies: activeId intentionally scrolls a newly selected session. React.useLayoutEffect(() => { const vp = viewport(); if (vp) { @@ -977,52 +1264,61 @@ export function SessionsTab({ taskId }: { taskId: string }) { } }, [activeId, viewport]); // new activity → stick to bottom only if the user is already pinned there + // biome-ignore lint/correctness/useExhaustiveDependencies: activity growth intentionally drives live-edge scrolling. React.useLayoutEffect(() => { if (!atBottomRef.current) return; const vp = viewport(); if (vp) vp.scrollTop = vp.scrollHeight; }, [activity, viewport]); + // Lazy detail loads (AnswerBlock / ToolBlock / Markdown) grow the content AFTER the + // activity array settles, WITHOUT changing its reference — so the layout effects + // above never re-fire and a freshly opened session would leave its last message + // scrolled partly off-screen (the final answer expands from a one-line summary to + // full markdown below the fold). A ResizeObserver re-pins to the bottom on any + // height change while the user is still at the bottom, so opening the main agent + // lands on the last message fully shown. contentRef's div is always mounted, so the + // observer catches the transcript mounting + each detail expanding. + // biome-ignore lint/correctness/useExhaustiveDependencies: activeId intentionally rebinds the observer to the new session's content. + React.useEffect(() => { + const el = contentRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(() => { + if (!atBottomRef.current) return; + const vp = viewport(); + if (vp) vp.scrollTop = vp.scrollHeight; + }); + ro.observe(el); + return () => ro.disconnect(); + }, [activeId, viewport]); function stop() { if (stopping) return; setStopping(true); - api.stopChat(taskId).finally(() => setStopping(false)); + void api.stopChat(taskId).finally(() => setStopping(false)); } function send() { const text = input.trim(); const atts = attachments; if ((!text && atts.length === 0) || sending) return; - const base = [...(store.main?.items ?? []), ...chatExtra]; - const nextSeq = Math.max(0, ...base.map((a) => a.seq)) + 1; - const userMsg: Activity = { - seq: nextSeq, - worker: "mainagent", - ts: new Date().toISOString(), - kind: "user", - summary: text, - // 有附件时把 {text, attachments} 塞进 detail,乐观气泡直接渲染卡片,无需再拉取。 - detail: atts.length > 0 ? JSON.stringify({ text, attachments: atts }) : undefined, - }; - // optimistic echo of the human turn only; the agent's steps + final answer - // stream back live via SSE (worker="mainagent"), so we don't append the reply. - setChatExtra((prev) => [...prev, userMsg]); + // No optimistic echo: the backend persists+broadcasts the human turn before it + // returns, so it streams back over SSE (worker="mainagent") with its real DB + // seq — the transcript renders it from server data like every other step. Clear + // the composer eagerly for responsiveness; restore it if the send fails. setInput(""); setAttachments([]); setSending(true); + chatStatusRequestRef.current++; api .chat(text, taskId, atts.length > 0 ? atts : undefined) - .catch(() => { - setChatExtra((prev) => [ - ...prev, - { - seq: nextSeq + 1, - worker: "mainagent", - ts: new Date().toISOString(), - kind: "text", - summary: "(发送失败,请稍后重试)", - }, - ]); + .then(({ mode }) => { + chatStatusRequestRef.current++; + setMainChatRunning(mode === "llm"); + }) + .catch((e) => { + setInput(text); // restore so the user doesn't lose their text / attachments + setAttachments(atts); + toast.error(`发送失败:${(e as Error).message || "请稍后重试"}`); }) .finally(() => setSending(false)); } @@ -1032,46 +1328,67 @@ export function SessionsTab({ taskId }: { taskId: string }) { const sendMode = useChatSendMode(); // What the transcript pane should show for the active session. const showLoader = !activeState || (activeState.loading && !activeState.loaded); + const resolutionForSession = (session: Session): TaskLLMResolution | undefined => { + if (!llmResolutions || session.inherited || session.role === "system") return undefined; + if (session.role === "mainagent") return llmResolutions.mainagent; + if (session.role === "planner") return llmResolutions.planner; + return llmResolutions.worker; + }; + const activeResolution = resolutionForSession(active); + const activeAssets = + active.role === "worker" && active.intent_id ? intentAssetsByID.get(active.intent_id) : undefined; return ( -
- {/* Left: session list */} -
-
-
-
会话列表
- {!MOCK && ( - - {sseLive ? ( - <> - - 实时 - - ) : ( - <> - - 重连中 - - )} - +
+ {/* Left: session list */} +
+
+
+
会话列表
+ {!MOCK && ( + + {sseLive ? ( + <> + + 实时 + + ) : ( + <> + + 重连中 + + )} + + )} +
+ {taskTokens && ( + + +
+ 任务总计 + · + +
+
+ + 输入 {taskTokens.input_tokens.toLocaleString()} · 输出 {taskTokens.output_tokens.toLocaleString()} · + 缓存读取 {taskTokens.cache_read_tokens.toLocaleString()} · 缓存写入{" "} + {taskTokens.cache_write_tokens.toLocaleString()} + +
)}
-<<<<<<< Updated upstream - {taskTokens && ( -
- 任务总计 · input {fmtTokens(taskTokens.input_tokens)} · cache{" "} - {fmtTokens(taskTokens.cache_read_tokens)} · output {fmtTokens(taskTokens.output_tokens)} -=======
{(["mainagent", "planner", "system", "worker"] as const).map((role) => { @@ -1126,81 +1443,33 @@ export function SessionsTab({ taskId }: { taskId: string }) { {mainLoaded && !workerSessions.length && (
暂无运行中的 Worker 会话。
)} ->>>>>>> Stashed changes
- )} +
- -
- {(["mainagent", "planner", "worker"] as const).map((role) => { - const items = grouped[role]; - if (!items.length) return null; - const Meta = roleMeta[role]; - return ( -
-
- - {Meta.label} -
- {items.map((s) => { - const meta = s.role === "worker" ? sessionMeta.get(s.id) : undefined; - return ( - setActiveId(s.id)} - /> - ); - })} - {role === "worker" && intentsHasMore && ( - - )} -
- ); - })} - {mainLoaded && !workerSessions.length && ( -
- 暂无运行中的 Worker 会话。 -
- )} -
-
-
- {/* Right: transcript */} -
-
- {(() => { - const isWorker = active.role === "worker"; - const meta = isWorker ? sessionMeta.get(active.id) : undefined; - // Worker: the intent moved into the transcript as a message, so the - // header shows a stable generic label (intent JSON stays on hover). - const title = isWorker ? "Worker 执行会话" : active.title; - const titleEl = {title}; - return meta?.json ? ( + {/* Right: transcript */} +
+
+ {(() => { + const isWorker = active.role === "worker"; + const meta = isWorker ? sessionMeta.get(active.id) : undefined; + // Worker: the intent moved into the transcript as a message, so the + // header shows a stable generic label (intent JSON stays on hover). + const title = isWorker ? "Worker 执行会话" : active.title; + const titleEl = {title}; + return meta?.json ? ( + + {titleEl} + +
{JSON.stringify(meta.json, null, 2)}
+
+
+ ) : ( + titleEl + ); + })()} + {activeResolution && ( -<<<<<<< Updated upstream - {titleEl} - -
-                    {JSON.stringify(meta.json, null, 2)}
-                  
-======= >>>>>> Stashed changes
- ) : titleEl; - })()} - {active.live && ( - - - 实时 - - )} - {activeState?.hasMore && ( - - ↑ 更早历史 - - )} -
- {tokenTotal.any && ( - - input {fmtTokens(tokenTotal.i)} · cache {fmtTokens(tokenTotal.cr)} · output {fmtTokens(tokenTotal.o)} + )} + {activeAssets && activeAssets.length > 0 && } + {active.inherited && active.source_task_id && ( + 来源任务 #{active.source_task_id} · 只读历史 + )} + {active.live && ( + + + 实时 )} - {runDuration != null && ( - - - {fmtDuration(runDuration)} + {activeState?.hasMore && ( + + ↑ 更早历史 )} - {isMain && 可交互} +
+ {tokenTotal.any && ( + + + + + + 输入 {activeTokens.input_tokens.toLocaleString()} · 输出{" "} + {activeTokens.output_tokens.toLocaleString()} · 缓存读取{" "} + {activeTokens.cache_read_tokens.toLocaleString()} · 缓存写入{" "} + {activeTokens.cache_write_tokens.toLocaleString()} + + + )} + {runDuration != null && ( + + + {fmtDuration(runDuration)} + + )} + {isMain && 可交互} +
-<<<<<<< Updated upstream -
- {/* Force Radix's internal viewport wrapper (display:table, sizes to content) -======= {(() => { const dm = active.role === "worker" ? sessionMeta.get(active.id) : undefined; if (!dm?.deleted) return null; @@ -1277,71 +1550,64 @@ export function SessionsTab({ taskId }: { taskId: string }) { ); })()} {/* Force Radix's internal viewport wrapper (display:table, sizes to content) ->>>>>>> Stashed changes to block so wide/unbreakable steps (long commands, code, URLs) can't blow out the width and defeat the truncation below — the transcript wraps to the panel instead of overflowing horizontally. */} - -
- {activeState?.loadingMore && ( -
- - 加载更早历史… -
- )} - {showLoader ? ( -
- - 加载活动流… -
- ) : activeState?.error ? ( -
- - 加载失败:{activeState.error} - -
- ) : activity.length ? ( - - ) : ( -
- {isMain - ? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。" - : "暂无活动记录。"} -
- )} -
-
- {isMain ? ( -
- {attachments.length > 0 && ( -
- {attachments.map((a) => ( -
- - {a.name} - {fmtBytes(a.size)} - +
+ ) : activity.length ? ( + + ) : ( +
+ {isMain ? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。" : "暂无活动记录。"} +
+ )} +
+ + {isMain ? ( +
+ {attachments.length > 0 && ( +
+ {attachments.map((a) => ( +
- - -
- ))} -
- )} -
+ + {a.name} + {fmtBytes(a.size)} + +
+ ))} +
+ )} void pickFiles(e.target.files)} /> -<<<<<<< Updated upstream - - setInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !mainLive) send(); - }} - disabled={mainLive} - /> - {mainLive ? ( - - ) : ( - - )} -
-
- ) : ( -
- api.activityDetail(seq, taskId).then((r) => r.detail ?? "")} - /> -
- )} -======= 删除 Worker #{cancelIntent?.intent_id}? - 删除会停止该意图(不再执行),并把删除原因作为一条事实挂到该意图上;意图、执行记录、已登记的事实和漏洞都会保留。规划者会收到「该意图由用户删除 + 原因」并据此重新规划。 + 删除会停止该意图 + (不再执行),并把删除原因作为一条事实挂到该意图上;意图、执行记录、已登记的事实和漏洞 + 都会保留。规划者会收到「该意图由用户删除 + 原因」并据此重新规划。
@@ -1493,9 +1722,7 @@ export function SessionsTab({ taskId }: { taskId: string }) { ->>>>>>> Stashed changes
-
); } diff --git a/web/src/app/(main)/function/tasks/detail/page.tsx b/web/src/app/(main)/function/tasks/detail/page.tsx index 3fe45e2..b07434a 100644 --- a/web/src/app/(main)/function/tasks/detail/page.tsx +++ b/web/src/app/(main)/function/tasks/detail/page.tsx @@ -9,15 +9,28 @@ import { PauseIcon, PlayIcon, BrainIcon, + CheckIcon, + CircleAlertIcon, } from "lucide-react"; +import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { SidebarTrigger } from "@/components/ui/sidebar"; import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Spinner } from "@/components/ui/spinner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { StatusBadge } from "@/components/status-badge"; import { api } from "@/lib/api"; -import type { Task } from "@/lib/types"; +import type { LLMProfile, Task } from "@/lib/types"; import { SessionsTab } from "./_tabs/sessions-tab"; import { OverviewTab } from "./_tabs/overview-tab"; @@ -39,6 +52,152 @@ const TABS = [ { value: "report", label: "报告" }, ]; +function taskProfileIDs(task: Task): string[] { + if (task.llm_profile_ids && task.llm_profile_ids.length > 0) { + return task.llm_profile_ids.map(String); + } + return task.llm_profile_id ? [String(task.llm_profile_id)] : []; +} + +function TaskLLMControl({ task, profiles, onUpdated }: { task: Task; profiles: LLMProfile[]; onUpdated: () => void }) { + const [open, setOpen] = React.useState(false); + const [profileIDs, setProfileIDs] = React.useState(() => taskProfileIDs(task)); + const [activeProfileID, setActiveProfileID] = React.useState( + task.active_llm_profile_id ? String(task.active_llm_profile_id) : (taskProfileIDs(task)[0] ?? ""), + ); + const [saving, setSaving] = React.useState(false); + const popoverContentRef = React.useRef(null); + + const chain = taskProfileIDs(task); + const exhausted = task.llm_failover_state === "chain_exhausted"; + const terminal = ["done", "failed", "timeout"].includes(task.status); + const editable = !terminal && (task.status === "running" || task.status === "paused" || exhausted); + // A null active profile on an exhausted, non-empty chain is a persisted end + // cursor. Keep the status display honest; choosing the first profile is only + // the editor's reset draft and does not mean it is currently active. + let activeID = chain[0] ?? ""; + if (exhausted) activeID = ""; + if (task.active_llm_profile_id) activeID = String(task.active_llm_profile_id); + const activeProfile = profiles.find((profile) => profile.id === activeID); + const currentLabel = exhausted + ? "配置链已耗尽" + : activeProfile?.name ?? (activeID ? `配置 #${activeID}` : "跟随默认配置"); + const activeIndex = chain.indexOf(activeID); + const backupCount = activeIndex >= 0 ? Math.max(0, chain.length - activeIndex - 1) : 0; + const currentTitle = [currentLabel, activeProfile?.model, backupCount > 0 ? `${backupCount} 个备用` : ""] + .filter(Boolean) + .join(" · "); + let editorDescription = "仅运行中、暂停或配置链耗尽的任务可以修改配置。"; + if (editable) editorDescription = "调整顺序或当前配置后,将从下一次 LLM 调用开始生效。"; + if (terminal) editorDescription = "已结束的任务仅支持查看配置。"; + let saveLabel = "保存"; + if (exhausted) saveLabel = "保存并重置"; + if (saving) saveLabel = "保存中"; + + const syncDraft = React.useCallback(() => { + const next = taskProfileIDs(task); + setProfileIDs(next); + setActiveProfileID(task.active_llm_profile_id ? String(task.active_llm_profile_id) : (next[0] ?? "")); + }, [task]); + + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (next) syncDraft(); + }; + + const handleProfileIDsChange = (next: string[]) => { + setProfileIDs(next); + setActiveProfileID((current) => (next.includes(current) ? current : (next[0] ?? ""))); + }; + + const save = async () => { + if (!editable) return; + setSaving(true); + try { + const result = await api.updateTaskLLMProfiles( + task.id, + profileIDs.map(Number), + activeProfileID ? Number(activeProfileID) : undefined, + ); + if (result.switch_event) { + toast.info(result.switch_event.summary, { id: `task-${task.id}-llm-${result.switch_event.seq}` }); + } else { + toast.success( + result.reopened_intents > 0 + ? `LLM 配置已更新,并恢复 ${result.reopened_intents} 条额度阻塞意图` + : "LLM 配置已更新", + ); + } + setOpen(false); + onUpdated(); + } catch (error) { + toast.error("更新失败:" + (error as Error).message); + } finally { + setSaving(false); + } + }; + + return ( + + + + + + + 任务 LLM 配置链 + {editorDescription} + + + {exhausted && ( + + + 配置链额度已耗尽 + + {task.llm_failover_reason ?? "所有已选配置均被判定为额度不足。保存配置链可重置故障状态。"} + + + )} + + + +
+ + {editable && ( + + )} +
+
+
+ ); +} + function TaskDetailInner() { const searchParams = useSearchParams(); const id = searchParams.get("id") ?? ""; @@ -47,42 +206,62 @@ function TaskDetailInner() { const [loaded, setLoaded] = React.useState(false); const [tab, setTab] = React.useState("sessions"); const [interceptPendingCount, setInterceptPendingCount] = React.useState(0); + const [profiles, setProfiles] = React.useState([]); + + React.useEffect(() => { + api + .llmProfiles() + .then(setProfiles) + .catch(() => setProfiles([])); + }, []); React.useEffect(() => { let alive = true; const load = () => - api.interceptTask(id) - .then((rows) => { if (alive) setInterceptPendingCount(rows.filter((r) => r.status === "pending").length); }) - .catch(() => {}); - load(); + api + .interceptTask(id) + .then((rows) => { + if (alive) setInterceptPendingCount(rows.filter((r) => r.status === "pending").length); + }) + .catch(() => { + // Polling is best-effort; the next interval retries automatically. + }); + void load(); const t = setInterval(load, 5000); - return () => { alive = false; clearInterval(t); }; + return () => { + alive = false; + clearInterval(t); + }; }, [id]); const load = React.useCallback(() => { - Promise.all([ - api.tasks(), - api.stats(id).catch(() => null), - ]) + Promise.all([api.tasks(), api.stats(id).catch(() => null)]) .then(([r, s]) => { const base = r.tasks.find((t) => t.id === id) ?? null; - const at = (s as { active_task?: Partial & { paused?: boolean } } | null)?.active_task; + const at = s?.active_task; if (base && at) { base.in_flight = at.in_flight; base.goals_total = at.goals_total; base.goals_met = at.goals_met; - base.engine_mode = at.engine_mode; + base.engine_mode = s?.engine_mode ?? at.engine_mode; base.paused = at.paused; } setTask(base); setPaused(at?.paused ?? base?.paused ?? false); }) - .catch(() => {}) + .catch(() => { + // Keep the last rendered task state during a transient poll failure. + }) .finally(() => setLoaded(true)); }, [id]); - React.useEffect(() => { load(); }, [load]); + React.useEffect(() => { + load(); + const timer = setInterval(load, 5000); + return () => clearInterval(timer); + }, [load]); async function togglePause() { + if (task && ["done", "failed", "timeout"].includes(task.status)) return; const next = !paused; try { await api.controlTask(id, next ? "pause" : "resume"); @@ -108,14 +287,24 @@ function TaskDetailInner() { ); } - const engineMode = paused ? "paused" : task.engine_mode ?? "idle"; + const completed = task.status === "done"; + const terminal = ["done", "failed", "timeout"].includes(task.status); + const engineMode = paused ? "paused" : (task.engine_mode ?? "idle"); + let controlVariant: "default" | "secondary" | "outline" = "outline"; + let controlIcon = ; + let controlLabel = "暂停"; + if (terminal) { + controlVariant = "secondary"; + controlIcon = ; + controlLabel = completed ? "已完成" : "已结束"; + } else if (paused) { + controlVariant = "default"; + controlIcon = ; + controlLabel = "恢复"; + } return ( - + {/* Top fixed area */}
@@ -125,48 +314,36 @@ function TaskDetailInner() { -<<<<<<< Updated upstream -

- {task.description} -=======

{task.name || task.description} ->>>>>>> Stashed changes

- + {task.id} - - {/* status pills */} - - LLM 已配置 - - -
- -
+ + + +

{task.goal}

{/* Tabs */} - - {TABS.map((t) => ( - - {t.label} - {t.value === "intercept" && interceptPendingCount > 0 && ( - - {interceptPendingCount > 99 ? "99+" : interceptPendingCount} - - )} - - ))} - +
+ + {TABS.map((t) => ( + + {t.label} + {t.value === "intercept" && interceptPendingCount > 0 && ( + + {interceptPendingCount > 99 ? "99+" : interceptPendingCount} + + )} + + ))} + +
{/* Tab content */} diff --git a/web/src/components/exploration-graph.tsx b/web/src/components/exploration-graph.tsx index 1fccfb5..34afa45 100644 --- a/web/src/components/exploration-graph.tsx +++ b/web/src/components/exploration-graph.tsx @@ -31,6 +31,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; import { StatusBadge } from "@/components/status-badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { @@ -235,7 +236,7 @@ function ExploreNode({ data, selected }: NodeProps) { const Icon = meta.icon; const showState = n.type === "goal" || n.type === "intent"; const showPriority = (n.type === "goal" || n.type === "intent") && n.priority > 0; - const isLive = n.type === "intent" && n.state === "running"; + const isLive = !n.inherited && n.type === "intent" && n.state === "running"; return (
) { {nodeSummary(n) || meta.label}
- {showState ? ( + {n.inherited && n.source_task_id ? ( + + 来源 #{n.source_task_id} + + ) : showState ? ( ) : ( {meta.label} @@ -334,6 +339,11 @@ function NodeDetailSheet({ {meta.label} {node.id}
+ {node.inherited && node.source_task_id && ( + + 来源任务 #{node.source_task_id} · 只读 + + )}
@@ -355,6 +365,11 @@ function NodeDetailSheet({ {node.origin} + {node.inherited && node.source_task_id && ( + + #{node.source_task_id}(继承,只读) + + )} {new Date(node.ts).toLocaleString("zh-CN")} @@ -398,7 +413,7 @@ function ExplorationGraphInner({ React.useEffect(() => { setSelected((cur) => (cur ? (nodes.find((n) => n.id === cur.id) ?? cur) : cur)); const liveTargets = new Set( - nodes.filter((n) => n.type === "intent" && n.state === "running").map((n) => n.id), + nodes.filter((n) => !n.inherited && n.type === "intent" && n.state === "running").map((n) => n.id), ); const layout = computeLayout(nodes, edges); setRfNodes((prev) => { diff --git a/web/src/components/ui/alert.tsx b/web/src/components/ui/alert.tsx index 1fe3176..5990340 100644 --- a/web/src/components/ui/alert.tsx +++ b/web/src/components/ui/alert.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const alertVariants = cva( - "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_minmax(0,1fr)] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", { variants: { variant: { @@ -39,7 +39,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + "min-w-0 font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", className )} {...props} @@ -55,7 +55,7 @@ function AlertDescription({
= { open: { label: "待领", tone: "slate" }, running: { label: "执行中", tone: "blue" }, + paused: { label: "已暂停", tone: "amber" }, done: { label: "已完成", tone: "green" }, blocked: { label: "被拦截", tone: "red" }, exhausted: { label: "已穷尽", tone: "violet" }, @@ -54,6 +55,7 @@ const intent: Record = { const task: Record = { created: { label: "已创建", tone: "slate" }, + queued: { label: "排队中", tone: "amber" }, running: { label: "运行中", tone: "blue" }, paused: { label: "已暂停", tone: "amber" }, done: { label: "已完成", tone: "green" },