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,
|
Waypoints,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { CoverageAssetRef, CoverageAssetRefs, CoverageGraphEdge, CoverageGraphNode } from "@/lib/types";
|
import type { CoverageAssetRef, CoverageAssetRefs, CoverageGraphEdge, CoverageGraphNode } from "@/lib/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// 每个父节点下、同一类型的子节点默认展示的数量;超出折叠,"展示更多"每次再拉这么多。
|
// 每个父节点下、同一类型的子节点默认展示的数量;超出折叠,"展示更多"每次再拉这么多。
|
||||||
const FOLD_LIMIT = 20;
|
const FOLD_LIMIT = 20;
|
||||||
@@ -274,10 +275,18 @@ function RefList({ title, items }: { title: string; items: CoverageAssetRef[] })
|
|||||||
</h4>
|
</h4>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{items.map((r) => (
|
{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">
|
<div
|
||||||
<span className="text-muted-foreground shrink-0 font-mono">#{r.id}</span>
|
key={`${r.kind}-${r.id}`}
|
||||||
{r.state && <span className="text-muted-foreground shrink-0">{r.state}</span>}
|
className="flex flex-wrap items-start gap-2 rounded-md bg-muted/50 px-2 py-1.5 text-xs"
|
||||||
<span className="text-foreground min-w-0 flex-1 break-words">{r.summary || "—"}</span>
|
>
|
||||||
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,43 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ActivityIcon,
|
ActivityIcon,
|
||||||
AlertTriangleIcon,
|
AlertTriangleIcon,
|
||||||
BugIcon,
|
BugIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
<<<<<<< Updated upstream
|
|
||||||
=======
|
|
||||||
CoinsIcon,
|
CoinsIcon,
|
||||||
ListChecksIcon,
|
ListChecksIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
>>>>>>> Stashed changes
|
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
ShieldAlertIcon,
|
ShieldAlertIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
TargetIcon,
|
TargetIcon,
|
||||||
<<<<<<< Updated upstream
|
|
||||||
=======
|
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
XIcon,
|
XIcon,
|
||||||
>>>>>>> Stashed changes
|
|
||||||
} from "lucide-react";
|
} 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 { 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";
|
import { api } from "@/lib/api";
|
||||||
<<<<<<< Updated upstream
|
|
||||||
import type { Task, Stats, TaskNode, Finding } from "@/lib/types";
|
|
||||||
=======
|
|
||||||
import type {
|
import type {
|
||||||
Finding,
|
Finding,
|
||||||
ModelTokenStat,
|
ModelTokenStat,
|
||||||
@@ -64,6 +53,7 @@ function cacheHitRate(cacheRead: number, input: number): string {
|
|||||||
|
|
||||||
// 测试范围一条的显示值:域名 / 网段 / 公司。
|
// 测试范围一条的显示值:域名 / 网段 / 公司。
|
||||||
function scopeValue(row: TaskScopeRow): string {
|
function scopeValue(row: TaskScopeRow): string {
|
||||||
|
if (row.value) return row.value;
|
||||||
if (row.domain) return row.domain;
|
if (row.domain) return row.domain;
|
||||||
if (row.net) return row.net;
|
if (row.net) return row.net;
|
||||||
if (row.company_id) return row.company_name || `企业 #${row.company_id}`;
|
if (row.company_id) return row.company_name || `企业 #${row.company_id}`;
|
||||||
@@ -76,6 +66,8 @@ const SCOPE_KIND_LABELS: Record<TaskScopeRow["kind"], string> = {
|
|||||||
subdomain: "子域名",
|
subdomain: "子域名",
|
||||||
ip: "IP",
|
ip: "IP",
|
||||||
cidr: "网段",
|
cidr: "网段",
|
||||||
|
icp: "ICP",
|
||||||
|
keyword: "关键词",
|
||||||
};
|
};
|
||||||
|
|
||||||
const SCOPE_SOURCE_LABELS: Record<TaskScopeRow["source"], string> = {
|
const SCOPE_SOURCE_LABELS: Record<TaskScopeRow["source"], string> = {
|
||||||
@@ -83,7 +75,6 @@ const SCOPE_SOURCE_LABELS: Record<TaskScopeRow["source"], string> = {
|
|||||||
agent: "Agent",
|
agent: "Agent",
|
||||||
manual: "手动",
|
manual: "手动",
|
||||||
};
|
};
|
||||||
>>>>>>> Stashed changes
|
|
||||||
|
|
||||||
function StatCard({
|
function StatCard({
|
||||||
label,
|
label,
|
||||||
@@ -104,9 +95,7 @@ function StatCard({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
<CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
|
<CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{sub && (
|
{sub && <CardContent className="text-xs text-muted-foreground">{sub}</CardContent>}
|
||||||
<CardContent className="text-xs text-muted-foreground">{sub}</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -126,8 +115,6 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
// 正在重跑的意图 id(含 "__all__" 表示批量),用于禁用按钮 + 转圈。
|
// 正在重跑的意图 id(含 "__all__" 表示批量),用于禁用按钮 + 转圈。
|
||||||
const [rerunning, setRerunning] = React.useState<Set<string>>(new Set());
|
const [rerunning, setRerunning] = React.useState<Set<string>>(new Set());
|
||||||
<<<<<<< Updated upstream
|
|
||||||
=======
|
|
||||||
// 测试范围列表 + 新增表单状态。
|
// 测试范围列表 + 新增表单状态。
|
||||||
const [scope, setScope] = React.useState<TaskScopeRow[]>([]);
|
const [scope, setScope] = React.useState<TaskScopeRow[]>([]);
|
||||||
const [scopeKind, setScopeKind] = React.useState<TaskScopeRow["kind"]>("root_domain");
|
const [scopeKind, setScopeKind] = React.useState<TaskScopeRow["kind"]>("root_domain");
|
||||||
@@ -322,7 +309,6 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
await loadScope(); // 删除失败:重新拉取还原
|
await loadScope(); // 删除失败:重新拉取还原
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
>>>>>>> Stashed changes
|
|
||||||
|
|
||||||
const markRerun = (key: string, on: boolean) =>
|
const markRerun = (key: string, on: boolean) =>
|
||||||
setRerunning((prev) => {
|
setRerunning((prev) => {
|
||||||
@@ -337,9 +323,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
markRerun(id, true);
|
markRerun(id, true);
|
||||||
try {
|
try {
|
||||||
await api.rerunIntent(taskId, id);
|
await api.rerunIntent(taskId, id);
|
||||||
setIntents((prev) =>
|
setIntents((prev) => prev.map((i) => (i.id === id ? { ...i, state: "open" } : i)));
|
||||||
prev.map((i) => (i.id === id ? { ...i, state: "open" } : i)),
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
// 失败忽略:下次轮询仍显示 blocked,用户可再点
|
// 失败忽略:下次轮询仍显示 blocked,用户可再点
|
||||||
} finally {
|
} finally {
|
||||||
@@ -352,9 +336,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
markRerun("__all__", true);
|
markRerun("__all__", true);
|
||||||
try {
|
try {
|
||||||
await api.rerunBlocked(taskId);
|
await api.rerunBlocked(taskId);
|
||||||
setIntents((prev) =>
|
setIntents((prev) => prev.map((i) => (i.state === "blocked" ? { ...i, state: "open" } : i)));
|
||||||
prev.map((i) => (i.state === "blocked" ? { ...i, state: "open" } : i)),
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
@@ -367,13 +349,12 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const [tasksResp, statsResp, intentsResp, findingsResp] =
|
const [tasksResp, statsResp, intentsResp, findingsResp] = await Promise.all([
|
||||||
await Promise.all([
|
api.tasks(),
|
||||||
api.tasks(),
|
api.stats(taskId),
|
||||||
api.stats(taskId),
|
api.intents(taskId),
|
||||||
api.intents(taskId),
|
api.findings(taskId),
|
||||||
api.findings(taskId),
|
]);
|
||||||
]);
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setTask(tasksResp.tasks.find((t) => t.id === taskId) ?? null);
|
setTask(tasksResp.tasks.find((t) => t.id === taskId) ?? null);
|
||||||
setStats(statsResp);
|
setStats(statsResp);
|
||||||
@@ -386,16 +367,15 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
.then((c) => {
|
.then((c) => {
|
||||||
if (!cancelled) setCoverage(c);
|
if (!cancelled) setCoverage(c);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {
|
||||||
|
// A transient coverage failure is retried by the next poll.
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// transient errors are ignored; the next poll will retry
|
// transient errors are ignored; the next poll will retry
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
load();
|
load();
|
||||||
<<<<<<< Updated upstream
|
|
||||||
const timer = setInterval(load, 3000);
|
|
||||||
=======
|
|
||||||
void loadScope();
|
void loadScope();
|
||||||
void loadTokens();
|
void loadTokens();
|
||||||
void loadGoals();
|
void loadGoals();
|
||||||
@@ -406,24 +386,29 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
void loadGoals();
|
void loadGoals();
|
||||||
void loadConstraints();
|
void loadConstraints();
|
||||||
}, 3000);
|
}, 3000);
|
||||||
>>>>>>> Stashed changes
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
};
|
};
|
||||||
<<<<<<< Updated upstream
|
|
||||||
}, [taskId]);
|
|
||||||
=======
|
|
||||||
}, [taskId, loadScope, loadTokens, loadGoals, loadConstraints]);
|
}, [taskId, loadScope, loadTokens, loadGoals, loadConstraints]);
|
||||||
>>>>>>> Stashed changes
|
|
||||||
|
|
||||||
const running = intents.filter((i) => i.state === "running");
|
const running = intents.filter((i) => i.state === "running");
|
||||||
const open = intents.filter((i) => i.state === "open");
|
const open = intents.filter((i) => i.state === "open");
|
||||||
const blocked = intents.filter((i) => i.state === "blocked");
|
const blocked = intents.filter((i) => i.state === "blocked");
|
||||||
const taskFindings = findings.filter((f) => f.task_id === taskId);
|
const taskFindings = findings.filter((f) => f.task_id === taskId);
|
||||||
const goalsPct = task?.goals_total
|
const goalsPct = task?.goals_total ? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100) : 0;
|
||||||
? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100)
|
// token 合计(跨全部模型),用于卡片头部总览。
|
||||||
: 0;
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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">
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="text-xs font-medium text-muted-foreground">描述</div>
|
<div className="text-xs font-medium text-muted-foreground">描述</div>
|
||||||
<p className="text-sm whitespace-pre-wrap break-words">
|
<p className="text-sm whitespace-pre-wrap break-words">{task?.description?.trim() || "—"}</p>
|
||||||
{task?.description?.trim() || "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="text-xs font-medium text-muted-foreground">目标</div>
|
<div className="text-xs font-medium text-muted-foreground">目标</div>
|
||||||
<p className="text-sm whitespace-pre-wrap break-words">
|
<p className="text-sm whitespace-pre-wrap break-words">{task?.goal?.trim() || "—"}</p>
|
||||||
{task?.goal?.trim() || "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -604,7 +585,12 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
}}
|
}}
|
||||||
disabled={conBusy}
|
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" /> 添加
|
<PlusIcon className="size-3.5" /> 添加
|
||||||
</Button>
|
</Button>
|
||||||
{conErr && <span className="text-xs text-red-500">{conErr}</span>}
|
{conErr && <span className="text-xs text-red-500">{conErr}</span>}
|
||||||
@@ -727,6 +713,163 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Heartbeat */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -746,17 +889,13 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">运行中 Worker</div>
|
<div className="text-xs text-muted-foreground">运行中 Worker</div>
|
||||||
<div className="mt-1 text-lg font-semibold tabular-nums">
|
<div className="mt-1 text-lg font-semibold tabular-nums">{running.length}</div>
|
||||||
{running.length}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">最近活动</div>
|
<div className="text-xs text-muted-foreground">最近活动</div>
|
||||||
<div className="mt-1 inline-flex items-center gap-1 text-sm">
|
<div className="mt-1 inline-flex items-center gap-1 text-sm">
|
||||||
<ClockIcon className="size-3.5" />
|
<ClockIcon className="size-3.5" />
|
||||||
{task?.last_activity
|
{task?.last_activity ? new Date(task.last_activity).toLocaleTimeString("zh-CN") : "—"}
|
||||||
? new Date(task.last_activity).toLocaleTimeString("zh-CN")
|
|
||||||
: "—"}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -792,9 +931,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
<span className="min-w-0 flex-1 truncate">{i.payload}</span>
|
<span className="min-w-0 flex-1 truncate">{i.payload}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{running.length === 0 && (
|
{running.length === 0 && <p className="text-sm text-muted-foreground">暂无进行中意图</p>}
|
||||||
<p className="text-sm text-muted-foreground">暂无进行中意图</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -806,27 +943,19 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-2 gap-3 text-sm">
|
<CardContent className="grid grid-cols-2 gap-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-semibold tabular-nums text-red-600">
|
<div className="text-2xl font-semibold tabular-nums text-red-600">{taskFindings.length}</div>
|
||||||
{taskFindings.length}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">确认漏洞</div>
|
<div className="text-xs text-muted-foreground">确认漏洞</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-semibold tabular-nums text-blue-600">
|
<div className="text-2xl font-semibold tabular-nums text-blue-600">{running.length}</div>
|
||||||
{running.length}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">执行中</div>
|
<div className="text-xs text-muted-foreground">执行中</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-semibold tabular-nums">
|
<div className="text-2xl font-semibold tabular-nums">{open.length}</div>
|
||||||
{open.length}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">frontier 待领</div>
|
<div className="text-xs text-muted-foreground">frontier 待领</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-semibold tabular-nums text-red-600">
|
<div className="text-2xl font-semibold tabular-nums text-red-600">{blocked.length}</div>
|
||||||
{blocked.length}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">被拦意图</div>
|
<div className="text-xs text-muted-foreground">被拦意图</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -845,9 +974,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
<span className="min-w-0 flex-1 truncate">{f.summary}</span>
|
<span className="min-w-0 flex-1 truncate">{f.summary}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{taskFindings.length === 0 && (
|
{taskFindings.length === 0 && <p className="text-sm text-muted-foreground">暂无发现</p>}
|
||||||
<p className="text-sm text-muted-foreground">暂无发现</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -859,19 +986,10 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0">
|
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0">
|
||||||
<CardTitle className="flex items-center gap-2 text-sm">
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
<AlertTriangleIcon className="size-4 text-red-500" /> 被拦/出错意图
|
<AlertTriangleIcon className="size-4 text-red-500" /> 被拦/出错意图
|
||||||
<span className="text-xs font-normal text-muted-foreground">
|
<span className="text-xs font-normal text-muted-foreground">(共 {blocked.length} 条,可重跑)</span>
|
||||||
(共 {blocked.length} 条,可重跑)
|
|
||||||
</span>
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button
|
<Button size="sm" variant="outline" disabled={rerunning.has("__all__")} onClick={() => void rerunAll()}>
|
||||||
size="sm"
|
<RefreshCwIcon className={`size-3.5 ${rerunning.has("__all__") ? "animate-spin" : ""}`} />
|
||||||
variant="outline"
|
|
||||||
disabled={rerunning.has("__all__")}
|
|
||||||
onClick={() => void rerunAll()}
|
|
||||||
>
|
|
||||||
<RefreshCwIcon
|
|
||||||
className={`size-3.5 ${rerunning.has("__all__") ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
全部重跑
|
全部重跑
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -887,9 +1005,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
disabled={rerunning.has(i.id)}
|
disabled={rerunning.has(i.id)}
|
||||||
onClick={() => void rerunOne(i.id)}
|
onClick={() => void rerunOne(i.id)}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon
|
<RefreshCwIcon className={`size-3 ${rerunning.has(i.id) ? "animate-spin" : ""}`} />
|
||||||
className={`size-3 ${rerunning.has(i.id) ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
重跑
|
重跑
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -905,24 +1021,9 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
|
|
||||||
{/* Stat cards */}
|
{/* Stat cards */}
|
||||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||||||
<StatCard
|
<StatCard label="待领意图" value={open.length} icon={ShieldCheckIcon} sub="frontier 开放" />
|
||||||
label="待领意图"
|
<StatCard label="确认发现" value={taskFindings.length} icon={BugIcon} sub="本任务" />
|
||||||
value={open.length}
|
<StatCard label="意图总数" value={intents.length} icon={AlertTriangleIcon} sub="本任务全部意图" />
|
||||||
icon={ShieldCheckIcon}
|
|
||||||
sub="frontier 开放"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="确认发现"
|
|
||||||
value={taskFindings.length}
|
|
||||||
icon={BugIcon}
|
|
||||||
sub="本任务"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="意图总数"
|
|
||||||
value={intents.length}
|
|
||||||
icon={AlertTriangleIcon}
|
|
||||||
sub="本任务全部意图"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -9,15 +9,28 @@ import {
|
|||||||
PauseIcon,
|
PauseIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
BrainIcon,
|
BrainIcon,
|
||||||
|
CheckIcon,
|
||||||
|
CircleAlertIcon,
|
||||||
} from "lucide-react";
|
} 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 { SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { StatusBadge } from "@/components/status-badge";
|
import { StatusBadge } from "@/components/status-badge";
|
||||||
import { api } from "@/lib/api";
|
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 { SessionsTab } from "./_tabs/sessions-tab";
|
||||||
import { OverviewTab } from "./_tabs/overview-tab";
|
import { OverviewTab } from "./_tabs/overview-tab";
|
||||||
@@ -39,6 +52,152 @@ const TABS = [
|
|||||||
{ value: "report", label: "报告" },
|
{ 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() {
|
function TaskDetailInner() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const id = searchParams.get("id") ?? "";
|
const id = searchParams.get("id") ?? "";
|
||||||
@@ -47,42 +206,62 @@ function TaskDetailInner() {
|
|||||||
const [loaded, setLoaded] = React.useState(false);
|
const [loaded, setLoaded] = React.useState(false);
|
||||||
const [tab, setTab] = React.useState("sessions");
|
const [tab, setTab] = React.useState("sessions");
|
||||||
const [interceptPendingCount, setInterceptPendingCount] = React.useState(0);
|
const [interceptPendingCount, setInterceptPendingCount] = React.useState(0);
|
||||||
|
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
api
|
||||||
|
.llmProfiles()
|
||||||
|
.then(setProfiles)
|
||||||
|
.catch(() => setProfiles([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const load = () =>
|
const load = () =>
|
||||||
api.interceptTask(id)
|
api
|
||||||
.then((rows) => { if (alive) setInterceptPendingCount(rows.filter((r) => r.status === "pending").length); })
|
.interceptTask(id)
|
||||||
.catch(() => {});
|
.then((rows) => {
|
||||||
load();
|
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);
|
const t = setInterval(load, 5000);
|
||||||
return () => { alive = false; clearInterval(t); };
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(t);
|
||||||
|
};
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const load = React.useCallback(() => {
|
const load = React.useCallback(() => {
|
||||||
Promise.all([
|
Promise.all([api.tasks(), api.stats(id).catch(() => null)])
|
||||||
api.tasks(),
|
|
||||||
api.stats(id).catch(() => null),
|
|
||||||
])
|
|
||||||
.then(([r, s]) => {
|
.then(([r, s]) => {
|
||||||
const base = r.tasks.find((t) => t.id === id) ?? null;
|
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) {
|
if (base && at) {
|
||||||
base.in_flight = at.in_flight;
|
base.in_flight = at.in_flight;
|
||||||
base.goals_total = at.goals_total;
|
base.goals_total = at.goals_total;
|
||||||
base.goals_met = at.goals_met;
|
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;
|
base.paused = at.paused;
|
||||||
}
|
}
|
||||||
setTask(base);
|
setTask(base);
|
||||||
setPaused(at?.paused ?? base?.paused ?? false);
|
setPaused(at?.paused ?? base?.paused ?? false);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {
|
||||||
|
// Keep the last rendered task state during a transient poll failure.
|
||||||
|
})
|
||||||
.finally(() => setLoaded(true));
|
.finally(() => setLoaded(true));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
React.useEffect(() => { load(); }, [load]);
|
React.useEffect(() => {
|
||||||
|
load();
|
||||||
|
const timer = setInterval(load, 5000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
async function togglePause() {
|
async function togglePause() {
|
||||||
|
if (task && ["done", "failed", "timeout"].includes(task.status)) return;
|
||||||
const next = !paused;
|
const next = !paused;
|
||||||
try {
|
try {
|
||||||
await api.controlTask(id, next ? "pause" : "resume");
|
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 (
|
return (
|
||||||
<Tabs
|
<Tabs value={tab} onValueChange={setTab} className="flex flex-1 flex-col gap-0">
|
||||||
value={tab}
|
|
||||||
onValueChange={setTab}
|
|
||||||
className="flex flex-1 flex-col gap-0"
|
|
||||||
>
|
|
||||||
{/* Top fixed area */}
|
{/* 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">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
@@ -125,48 +314,36 @@ function TaskDetailInner() {
|
|||||||
<ArrowLeftIcon />
|
<ArrowLeftIcon />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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}>
|
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold" title={task.name || task.description}>
|
||||||
{task.name || task.description}
|
{task.name || task.description}
|
||||||
>>>>>>> Stashed changes
|
|
||||||
</h1>
|
</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}
|
{task.id}
|
||||||
</code>
|
</code>
|
||||||
<Separator orientation="vertical" className="mx-1 h-4" />
|
<Separator orientation="vertical" className="mx-1 hidden h-4 sm:block" />
|
||||||
{/* status pills */}
|
<TaskLLMControl task={task} profiles={profiles} onUpdated={load} />
|
||||||
<span className="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs">
|
<StatusBadge domain={terminal ? "task" : "engine"} value={terminal ? task.status : engineMode} dot />
|
||||||
<BrainIcon className="size-3.5 text-emerald-500" /> LLM 已配置
|
<Button size="sm" variant={controlVariant} onClick={togglePause} disabled={terminal}>
|
||||||
</span>
|
{controlIcon}
|
||||||
<StatusBadge domain="engine" value={engineMode} dot />
|
{controlLabel}
|
||||||
<div className="ml-auto">
|
</Button>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={paused ? "default" : "outline"}
|
|
||||||
onClick={togglePause}
|
|
||||||
>
|
|
||||||
{paused ? <PlayIcon /> : <PauseIcon />}
|
|
||||||
{paused ? "恢复" : "暂停"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="truncate text-xs text-muted-foreground">{task.goal}</p>
|
<p className="truncate text-xs text-muted-foreground">{task.goal}</p>
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<TabsList variant="default">
|
<div className="no-scrollbar min-w-0 overflow-x-auto">
|
||||||
{TABS.map((t) => (
|
<TabsList variant="default" className="min-w-max">
|
||||||
<TabsTrigger key={t.value} value={t.value}>
|
{TABS.map((t) => (
|
||||||
{t.label}
|
<TabsTrigger key={t.value} value={t.value}>
|
||||||
{t.value === "intercept" && interceptPendingCount > 0 && (
|
{t.label}
|
||||||
<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">
|
{t.value === "intercept" && interceptPendingCount > 0 && (
|
||||||
{interceptPendingCount > 99 ? "99+" : interceptPendingCount}
|
<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">
|
||||||
</span>
|
{interceptPendingCount > 99 ? "99+" : interceptPendingCount}
|
||||||
)}
|
</span>
|
||||||
</TabsTrigger>
|
)}
|
||||||
))}
|
</TabsTrigger>
|
||||||
</TabsList>
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Tab content */}
|
{/* Tab content */}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { StatusBadge } from "@/components/status-badge";
|
import { StatusBadge } from "@/components/status-badge";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
@@ -235,7 +236,7 @@ function ExploreNode({ data, selected }: NodeProps<ExploreRFNode>) {
|
|||||||
const Icon = meta.icon;
|
const Icon = meta.icon;
|
||||||
const showState = n.type === "goal" || n.type === "intent";
|
const showState = n.type === "goal" || n.type === "intent";
|
||||||
const showPriority = (n.type === "goal" || n.type === "intent") && n.priority > 0;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -273,7 +274,11 @@ function ExploreNode({ data, selected }: NodeProps<ExploreRFNode>) {
|
|||||||
{nodeSummary(n) || meta.label}
|
{nodeSummary(n) || meta.label}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2 px-3 pb-2.5">
|
<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]" />
|
<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>
|
<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>
|
<SheetTitle className="leading-tight">{meta.label}</SheetTitle>
|
||||||
<span className="text-muted-foreground font-mono text-xs">{node.id}</span>
|
<span className="text-muted-foreground font-mono text-xs">{node.id}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{node.inherited && node.source_task_id && (
|
||||||
|
<Badge variant="outline" className="ml-auto shrink-0">
|
||||||
|
来源任务 #{node.source_task_id} · 只读
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
@@ -355,6 +365,11 @@ function NodeDetailSheet({
|
|||||||
<DetailRow label="来源">
|
<DetailRow label="来源">
|
||||||
<span className="font-mono text-xs">{node.origin}</span>
|
<span className="font-mono text-xs">{node.origin}</span>
|
||||||
</DetailRow>
|
</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>
|
<DetailRow label="时间">{new Date(node.ts).toLocaleString("zh-CN")}</DetailRow>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -398,7 +413,7 @@ function ExplorationGraphInner({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setSelected((cur) => (cur ? (nodes.find((n) => n.id === cur.id) ?? cur) : cur));
|
setSelected((cur) => (cur ? (nodes.find((n) => n.id === cur.id) ?? cur) : cur));
|
||||||
const liveTargets = new Set(
|
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);
|
const layout = computeLayout(nodes, edges);
|
||||||
setRfNodes((prev) => {
|
setRfNodes((prev) => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const alertVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -39,7 +39,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
<div
|
<div
|
||||||
data-slot="alert-title"
|
data-slot="alert-title"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -55,7 +55,7 @@ function AlertDescription({
|
|||||||
<div
|
<div
|
||||||
data-slot="alert-description"
|
data-slot="alert-description"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ interface StatusMeta {
|
|||||||
const intent: Record<string, StatusMeta> = {
|
const intent: Record<string, StatusMeta> = {
|
||||||
open: { label: "待领", tone: "slate" },
|
open: { label: "待领", tone: "slate" },
|
||||||
running: { label: "执行中", tone: "blue" },
|
running: { label: "执行中", tone: "blue" },
|
||||||
|
paused: { label: "已暂停", tone: "amber" },
|
||||||
done: { label: "已完成", tone: "green" },
|
done: { label: "已完成", tone: "green" },
|
||||||
blocked: { label: "被拦截", tone: "red" },
|
blocked: { label: "被拦截", tone: "red" },
|
||||||
exhausted: { label: "已穷尽", tone: "violet" },
|
exhausted: { label: "已穷尽", tone: "violet" },
|
||||||
@@ -54,6 +55,7 @@ const intent: Record<string, StatusMeta> = {
|
|||||||
|
|
||||||
const task: Record<string, StatusMeta> = {
|
const task: Record<string, StatusMeta> = {
|
||||||
created: { label: "已创建", tone: "slate" },
|
created: { label: "已创建", tone: "slate" },
|
||||||
|
queued: { label: "排队中", tone: "amber" },
|
||||||
running: { label: "运行中", tone: "blue" },
|
running: { label: "运行中", tone: "blue" },
|
||||||
paused: { label: "已暂停", tone: "amber" },
|
paused: { label: "已暂停", tone: "amber" },
|
||||||
done: { label: "已完成", tone: "green" },
|
done: { label: "已完成", tone: "green" },
|
||||||
|
|||||||
Reference in new issue
Block a user