feat(tasks): enhance detail sessions and runtime status

This commit is contained in:
pphua committed 2026-08-24 19:30:44 +08:00
1 parent af3149901e
commit 5863c12886
7 files changed
+1115 -584

No files matched your search

@@ -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[] })
</h4>
<div className="flex flex-col gap-1">
{items.map((r) => (
<div key={`${r.kind}-${r.id}`} className="bg-muted/50 flex items-start gap-2 rounded-md px-2 py-1.5 text-xs">
<span className="text-muted-foreground shrink-0 font-mono">#{r.id}</span>
{r.state && <span className="text-muted-foreground shrink-0">{r.state}</span>}
<span className="text-foreground min-w-0 flex-1 break-words">{r.summary || "—"}</span>
<div
key={`${r.kind}-${r.id}`}
className="flex flex-wrap items-start gap-2 rounded-md bg-muted/50 px-2 py-1.5 text-xs"
>
<span className="shrink-0 font-mono text-muted-foreground">#{r.id}</span>
{r.state && <span className="shrink-0 text-muted-foreground">{r.state}</span>}
<span className="min-w-32 flex-1 break-words text-foreground">{r.summary || "—"}</span>
{r.inherited && r.source_task_id && (
<Badge variant="outline" className="shrink-0">
来源 #{r.source_task_id} · 只读
</Badge>
)}
</div>
))}
</div>
@@ -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<TaskScopeRow["kind"], string> = {
subdomain: "子域名",
ip: "IP",
cidr: "网段",
icp: "ICP",
keyword: "关键词",
};
const SCOPE_SOURCE_LABELS: Record<TaskScopeRow["source"], string> = {
@@ -83,7 +75,6 @@ const SCOPE_SOURCE_LABELS: Record<TaskScopeRow["source"], string> = {
agent: "Agent",
manual: "手动",
};
>>>>>>> Stashed changes
function StatCard({
label,
@@ -104,9 +95,7 @@ function StatCard({
</CardDescription>
<CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
</CardHeader>
{sub && (
<CardContent className="text-xs text-muted-foreground">{sub}</CardContent>
)}
{sub && <CardContent className="text-xs text-muted-foreground">{sub}</CardContent>}
</Card>
);
}
@@ -126,8 +115,6 @@ export function OverviewTab({ taskId }: { taskId: string }) {
} | null>(null);
// 正在重跑的意图 id(含 "__all__" 表示批量),用于禁用按钮 + 转圈。
const [rerunning, setRerunning] = React.useState<Set<string>>(new Set());
<<<<<<< Updated upstream
=======
// 测试范围列表 + 新增表单状态。
const [scope, setScope] = React.useState<TaskScopeRow[]>([]);
const [scopeKind, setScopeKind] = React.useState<TaskScopeRow["kind"]>("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 (
<div className="flex flex-col gap-4">
@@ -437,15 +422,11 @@ export function OverviewTab({ taskId }: { taskId: string }) {
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<div className="text-xs font-medium text-muted-foreground">描述</div>
<p className="text-sm whitespace-pre-wrap break-words">
{task?.description?.trim() || "—"}
</p>
<p className="text-sm whitespace-pre-wrap break-words">{task?.description?.trim() || "—"}</p>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-xs font-medium text-muted-foreground">目标</div>
<p className="text-sm whitespace-pre-wrap break-words">
{task?.goal?.trim() || "—"}
</p>
<p className="text-sm whitespace-pre-wrap break-words">{task?.goal?.trim() || "—"}</p>
</div>
</CardContent>
</Card>
@@ -604,7 +585,12 @@ export function OverviewTab({ taskId }: { taskId: string }) {
}}
disabled={conBusy}
/>
<Button size="sm" variant="outline" disabled={conBusy || !conText.trim()} onClick={() => void addConstraint()}>
<Button
size="sm"
variant="outline"
disabled={conBusy || !conText.trim()}
onClick={() => void addConstraint()}
>
<PlusIcon className="size-3.5" /> 添加
</Button>
{conErr && <span className="text-xs text-red-500">{conErr}</span>}
@@ -727,6 +713,163 @@ export function OverviewTab({ taskId }: { taskId: string }) {
</CardContent>
</Card>
)}
{/* LLM Token 用量:按模型分组,数据来自 llm_records(需开启 LLM 录制)。 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CoinsIcon className="size-4 text-amber-500" /> LLM Token 用量
<span className="text-muted-foreground text-xs font-normal">
(按模型统计{tokenTotals.calls > 0 ? `,共 ${tokenTotals.calls} 次调用` : ""})
</span>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{modelTokens.length > 0 ? (
<>
{/* 合计总览 */}
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 text-sm">
<span className="tabular-nums">
<span className="text-muted-foreground">输入 </span>
<span className="font-semibold">{fmtTokens(tokenTotals.input)}</span>
</span>
<span className="tabular-nums">
<span className="text-muted-foreground">输出 </span>
<span className="font-semibold">{fmtTokens(tokenTotals.output)}</span>
</span>
<span className="tabular-nums">
<span className="text-muted-foreground">缓存读 </span>
<span className="font-semibold">{fmtTokens(tokenTotals.cacheRead)}</span>
</span>
<span className="tabular-nums">
<span className="text-muted-foreground">缓存命中率 </span>
<span className="font-semibold text-emerald-500">
{cacheHitRate(tokenTotals.cacheRead, tokenTotals.input)}
</span>
</span>
</div>
{/* 按模型明细表 */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-left text-xs">
<th className="py-1.5 pr-3 font-medium">模型</th>
<th className="py-1.5 pr-3 text-right font-medium">调用</th>
<th className="py-1.5 pr-3 text-right font-medium">输入</th>
<th className="py-1.5 pr-3 text-right font-medium">输出</th>
<th className="py-1.5 pr-3 text-right font-medium">缓存读</th>
<th className="py-1.5 text-right font-medium">命中率</th>
</tr>
</thead>
<tbody>
{modelTokens.map((m) => (
<tr key={m.model} className="border-b last:border-0">
<td className="max-w-[16rem] truncate py-1.5 pr-3 font-mono text-xs" title={m.model}>
{m.model}
</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{m.calls}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{fmtTokens(m.input_tokens)}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{fmtTokens(m.output_tokens)}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{fmtTokens(m.cache_read_tokens)}</td>
<td className="py-1.5 text-right tabular-nums text-emerald-500">
{cacheHitRate(m.cache_read_tokens, m.input_tokens)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : (
<p className="text-muted-foreground text-sm">暂无 LLM 用量(任务尚未产生调用,或记录仍在写入)。</p>
)}
</CardContent>
</Card>
{/* 测试范围:覆盖度分母 + 授权边界,可手动增删。 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheckIcon className="size-4 text-emerald-500" /> 测试范围
<span className="text-muted-foreground text-xs font-normal">
(覆盖度分母 + 授权边界,共 {scope.length} 条)
</span>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{/* 新增表单 */}
<div className="flex flex-wrap items-center gap-2">
<NativeSelect
size="sm"
value={scopeKind}
onChange={(e) => setScopeKind(e.target.value as TaskScopeRow["kind"])}
>
<NativeSelectOption value="root_domain">根域名</NativeSelectOption>
<NativeSelectOption value="subdomain">子域名</NativeSelectOption>
<NativeSelectOption value="ip">IP</NativeSelectOption>
<NativeSelectOption value="cidr">网段</NativeSelectOption>
<NativeSelectOption value="icp">ICP</NativeSelectOption>
<NativeSelectOption value="keyword">关键词</NativeSelectOption>
<NativeSelectOption value="company">公司</NativeSelectOption>
</NativeSelect>
<Input
className="h-7 w-56 text-sm"
placeholder={
scopeKind === "company"
? "公司名或 id"
: scopeKind === "ip" || scopeKind === "cidr"
? "如 10.0.0.1 或 10.0.0.0/24"
: scopeKind === "icp"
? "如 京ICP备12345678号-1"
: scopeKind === "keyword"
? "如 企业名称关键词"
: "如 example.com"
}
value={scopeValueInput}
onChange={(e) => setScopeValueInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addScope();
}}
disabled={scopeBusy}
/>
<Button
size="sm"
variant="outline"
disabled={scopeBusy || !scopeValueInput.trim()}
onClick={() => void addScope()}
>
<PlusIcon className="size-3.5" /> 添加
</Button>
{scopeErr && <span className="text-xs text-red-500">{scopeErr}</span>}
</div>
{/* 范围列表 */}
{scope.length > 0 ? (
<div className="flex flex-col gap-1.5">
{scope.map((row) => (
<div key={row.id} className="flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-xs">
{SCOPE_KIND_LABELS[row.kind]}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{scopeValue(row)}</span>
<span className="text-muted-foreground shrink-0 text-xs">{SCOPE_SOURCE_LABELS[row.source]}</span>
{row.task_id.toString() === taskId ? (
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
onClick={() => void removeScope(row)}
>
<Trash2Icon className="size-3.5 text-red-500" />
</Button>
) : (
<span className="text-muted-foreground shrink-0 text-xs">继承</span>
)}
</div>
))}
</div>
) : (
<p className="text-muted-foreground text-sm">暂无测试范围,添加后可作为资产覆盖度的分母。</p>
)}
</CardContent>
</Card>
{/* Heartbeat */}
<Card>
<CardHeader>
@@ -746,17 +889,13 @@ export function OverviewTab({ taskId }: { taskId: string }) {
</div>
<div>
<div className="text-xs text-muted-foreground">运行中 Worker</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{running.length}
</div>
<div className="mt-1 text-lg font-semibold tabular-nums">{running.length}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">最近活动</div>
<div className="mt-1 inline-flex items-center gap-1 text-sm">
<ClockIcon className="size-3.5" />
{task?.last_activity
? new Date(task.last_activity).toLocaleTimeString("zh-CN")
: "—"}
{task?.last_activity ? new Date(task.last_activity).toLocaleTimeString("zh-CN") : "—"}
</div>
</div>
<div>
@@ -792,9 +931,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
<span className="min-w-0 flex-1 truncate">{i.payload}</span>
</div>
))}
{running.length === 0 && (
<p className="text-sm text-muted-foreground">暂无进行中意图</p>
)}
{running.length === 0 && <p className="text-sm text-muted-foreground">暂无进行中意图</p>}
</CardContent>
</Card>
@@ -806,27 +943,19 @@ export function OverviewTab({ taskId }: { taskId: string }) {
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm">
<div>
<div className="text-2xl font-semibold tabular-nums text-red-600">
{taskFindings.length}
</div>
<div className="text-2xl font-semibold tabular-nums text-red-600">{taskFindings.length}</div>
<div className="text-xs text-muted-foreground">确认漏洞</div>
</div>
<div>
<div className="text-2xl font-semibold tabular-nums text-blue-600">
{running.length}
</div>
<div className="text-2xl font-semibold tabular-nums text-blue-600">{running.length}</div>
<div className="text-xs text-muted-foreground">执行中</div>
</div>
<div>
<div className="text-2xl font-semibold tabular-nums">
{open.length}
</div>
<div className="text-2xl font-semibold tabular-nums">{open.length}</div>
<div className="text-xs text-muted-foreground">frontier 待领</div>
</div>
<div>
<div className="text-2xl font-semibold tabular-nums text-red-600">
{blocked.length}
</div>
<div className="text-2xl font-semibold tabular-nums text-red-600">{blocked.length}</div>
<div className="text-xs text-muted-foreground">被拦意图</div>
</div>
</CardContent>
@@ -845,9 +974,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
<span className="min-w-0 flex-1 truncate">{f.summary}</span>
</div>
))}
{taskFindings.length === 0 && (
<p className="text-sm text-muted-foreground">暂无发现</p>
)}
{taskFindings.length === 0 && <p className="text-sm text-muted-foreground">暂无发现</p>}
</CardContent>
</Card>
</div>
@@ -859,19 +986,10 @@ export function OverviewTab({ taskId }: { taskId: string }) {
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="flex items-center gap-2 text-sm">
<AlertTriangleIcon className="size-4 text-red-500" /> 被拦/出错意图
<span className="text-xs font-normal text-muted-foreground">
(共 {blocked.length} 条,可重跑)
</span>
<span className="text-xs font-normal text-muted-foreground">(共 {blocked.length} 条,可重跑)</span>
</CardTitle>
<Button
size="sm"
variant="outline"
disabled={rerunning.has("__all__")}
onClick={() => void rerunAll()}
>
<RefreshCwIcon
className={`size-3.5 ${rerunning.has("__all__") ? "animate-spin" : ""}`}
/>
<Button size="sm" variant="outline" disabled={rerunning.has("__all__")} onClick={() => void rerunAll()}>
<RefreshCwIcon className={`size-3.5 ${rerunning.has("__all__") ? "animate-spin" : ""}`} />
全部重跑
</Button>
</CardHeader>
@@ -887,9 +1005,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
disabled={rerunning.has(i.id)}
onClick={() => void rerunOne(i.id)}
>
<RefreshCwIcon
className={`size-3 ${rerunning.has(i.id) ? "animate-spin" : ""}`}
/>
<RefreshCwIcon className={`size-3 ${rerunning.has(i.id) ? "animate-spin" : ""}`} />
重跑
</Button>
</div>
@@ -905,24 +1021,9 @@ export function OverviewTab({ taskId }: { taskId: string }) {
{/* Stat cards */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
<StatCard
label="待领意图"
value={open.length}
icon={ShieldCheckIcon}
sub="frontier 开放"
/>
<StatCard
label="确认发现"
value={taskFindings.length}
icon={BugIcon}
sub="本任务"
/>
<StatCard
label="意图总数"
value={intents.length}
icon={AlertTriangleIcon}
sub="本任务全部意图"
/>
<StatCard label="待领意图" value={open.length} icon={ShieldCheckIcon} sub="frontier 开放" />
<StatCard label="确认发现" value={taskFindings.length} icon={BugIcon} sub="本任务" />
<StatCard label="意图总数" value={intents.length} icon={AlertTriangleIcon} sub="本任务全部意图" />
</div>
</div>
);
File diff suppressed because it is too large. Load diff
+231 -54
View File
@@ -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<string[]>(() => 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<HTMLDivElement>(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 (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
size="sm"
variant={exhausted ? "destructive" : "outline"}
aria-label="查看或切换任务 LLM 配置"
title={currentTitle}
>
<BrainIcon data-icon="inline-start" />
<span className="hidden max-w-36 truncate lg:inline">{currentLabel}</span>
{backupCount > 0 && <span className="hidden text-muted-foreground xl:inline">+{backupCount}</span>}
</Button>
</PopoverTrigger>
<PopoverContent
ref={popoverContentRef}
align="start"
className="w-[min(28rem,calc(100vw-2rem))] gap-4 p-4"
>
<PopoverHeader>
<PopoverTitle>任务 LLM 配置链</PopoverTitle>
<PopoverDescription>{editorDescription}</PopoverDescription>
</PopoverHeader>
{exhausted && (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>配置链额度已耗尽</AlertTitle>
<AlertDescription>
{task.llm_failover_reason ?? "所有已选配置均被判定为额度不足。保存配置链可重置故障状态。"}
</AlertDescription>
</Alert>
)}
<TaskLLMProfileChain
profiles={profiles}
value={profileIDs}
onValueChange={handleProfileIDsChange}
activeProfileId={activeProfileID}
onActiveProfileChange={setActiveProfileID}
inputId="task-llm-profiles"
disabled={!editable || saving}
portalContainer={popoverContentRef}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)}>
关闭
</Button>
{editable && (
<Button type="button" size="sm" onClick={save} disabled={saving}>
{saving && <Spinner data-icon="inline-start" />}
{saveLabel}
</Button>
)}
</div>
</PopoverContent>
</Popover>
);
}
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<LLMProfile[]>([]);
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<Task> & { 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 = <PauseIcon data-icon="inline-start" />;
let controlLabel = "暂停";
if (terminal) {
controlVariant = "secondary";
controlIcon = <CheckIcon data-icon="inline-start" />;
controlLabel = completed ? "已完成" : "已结束";
} else if (paused) {
controlVariant = "default";
controlIcon = <PlayIcon data-icon="inline-start" />;
controlLabel = "恢复";
}
return (
<Tabs
value={tab}
onValueChange={setTab}
className="flex flex-1 flex-col gap-0"
>
<Tabs value={tab} onValueChange={setTab} className="flex flex-1 flex-col gap-0">
{/* Top fixed area */}
<header className="sticky top-0 z-10 flex flex-col gap-2 border-b bg-background/95 px-4 py-2.5 backdrop-blur lg:px-6">
<div className="flex items-center gap-2">
@@ -125,48 +314,36 @@ function TaskDetailInner() {
<ArrowLeftIcon />
</Link>
</Button>
<<<<<<< Updated upstream
<h1 className="max-w-md truncate text-sm font-semibold" title={task.description}>
{task.description}
=======
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold" title={task.name || task.description}>
{task.name || task.description}
>>>>>>> Stashed changes
</h1>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
<code className="hidden rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground sm:inline">
{task.id}
</code>
<Separator orientation="vertical" className="mx-1 h-4" />
{/* status pills */}
<span className="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs">
<BrainIcon className="size-3.5 text-emerald-500" /> LLM 已配置
</span>
<StatusBadge domain="engine" value={engineMode} dot />
<div className="ml-auto">
<Button
size="sm"
variant={paused ? "default" : "outline"}
onClick={togglePause}
>
{paused ? <PlayIcon /> : <PauseIcon />}
{paused ? "恢复" : "暂停"}
</Button>
</div>
<Separator orientation="vertical" className="mx-1 hidden h-4 sm:block" />
<TaskLLMControl task={task} profiles={profiles} onUpdated={load} />
<StatusBadge domain={terminal ? "task" : "engine"} value={terminal ? task.status : engineMode} dot />
<Button size="sm" variant={controlVariant} onClick={togglePause} disabled={terminal}>
{controlIcon}
{controlLabel}
</Button>
</div>
<p className="truncate text-xs text-muted-foreground">{task.goal}</p>
{/* Tabs */}
<TabsList variant="default">
{TABS.map((t) => (
<TabsTrigger key={t.value} value={t.value}>
{t.label}
{t.value === "intercept" && interceptPendingCount > 0 && (
<span className="ml-1.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-semibold leading-none text-white">
{interceptPendingCount > 99 ? "99+" : interceptPendingCount}
</span>
)}
</TabsTrigger>
))}
</TabsList>
<div className="no-scrollbar min-w-0 overflow-x-auto">
<TabsList variant="default" className="min-w-max">
{TABS.map((t) => (
<TabsTrigger key={t.value} value={t.value}>
{t.label}
{t.value === "intercept" && interceptPendingCount > 0 && (
<span className="ml-1.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-semibold leading-none text-white">
{interceptPendingCount > 99 ? "99+" : interceptPendingCount}
</span>
)}
</TabsTrigger>
))}
</TabsList>
</div>
</header>
{/* Tab content */}
+18 -3
View File
@@ -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<ExploreRFNode>) {
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 (
<div
@@ -273,7 +274,11 @@ function ExploreNode({ data, selected }: NodeProps<ExploreRFNode>) {
{nodeSummary(n) || meta.label}
</div>
<div className="flex items-center justify-between gap-2 px-3 pb-2.5">
{showState ? (
{n.inherited && n.source_task_id ? (
<Badge variant="outline" className="max-w-32 truncate" title={`继承自任务 #${n.source_task_id},只读`}>
来源 #{n.source_task_id}
</Badge>
) : showState ? (
<StatusBadge domain={n.type === "goal" ? "goal" : "intent"} value={n.state} dot className="px-1.5 py-0 text-[10px]" />
) : (
<span className="text-[10px] text-neutral-400 dark:text-neutral-500">{meta.label}</span>
@@ -334,6 +339,11 @@ function NodeDetailSheet({
<SheetTitle className="leading-tight">{meta.label}</SheetTitle>
<span className="text-muted-foreground font-mono text-xs">{node.id}</span>
</div>
{node.inherited && node.source_task_id && (
<Badge variant="outline" className="ml-auto shrink-0">
来源任务 #{node.source_task_id} · 只读
</Badge>
)}
</div>
</SheetHeader>
@@ -355,6 +365,11 @@ function NodeDetailSheet({
<DetailRow label="来源">
<span className="font-mono text-xs">{node.origin}</span>
</DetailRow>
{node.inherited && node.source_task_id && (
<DetailRow label="来源任务">
<span className="font-mono text-xs">#{node.source_task_id}(继承,只读)</span>
</DetailRow>
)}
<DetailRow label="时间">{new Date(node.ts).toLocaleString("zh-CN")}</DetailRow>
</section>
@@ -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) => {
+3 -3
View File
@@ -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">) {
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>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({
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
"min-w-0 text-sm text-balance text-muted-foreground [overflow-wrap:anywhere] md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
+2
View File
@@ -47,6 +47,7 @@ interface StatusMeta {
const intent: Record<string, StatusMeta> = {
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<string, StatusMeta> = {
const task: Record<string, StatusMeta> = {
created: { label: "已创建", tone: "slate" },
queued: { label: "排队中", tone: "amber" },
running: { label: "运行中", tone: "blue" },
paused: { label: "已暂停", tone: "amber" },
done: { label: "已完成", tone: "green" },