feat(tasks): enhance detail sessions and runtime status
This commit is contained in:
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
@@ -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 */}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user