feat(web): 播报板节点详情展示上游节点信息与锚定资产
上游/下游节点从只显示 id 改为悬停弹出节点名片(类型/状态/来源/时间/摘要/payload); 展开时顺带展示该节点锚定的资产(类型标签 + 可辨识文本)。 服务端 /exploration/nodes 新增 assets 字段(节点id→资产),读 exploration_anchors 并复用发现列表的资产打标逻辑;同页节点互引时用本页 items 兜底 refs 查找表。
This commit is contained in:
1 parent
3b60191e86
commit
d95f0d002d
6 files changed
+214
-36
No files matched your search
+31
-6
@@ -2542,15 +2542,40 @@ func (s *Server) explorationNodes(w http.ResponseWriter, r *http.Request) {
|
||||
refs[i64s(n.ID)] = taskNodeDTO(n)
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"items": taskNodeDTOs(nodes),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"edges": edgeDTOs(edges),
|
||||
"refs": refs,
|
||||
"items": taskNodeDTOs(nodes),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"edges": edgeDTOs(edges),
|
||||
"refs": refs,
|
||||
"assets": s.nodeAnchoredAssets(t, append(ids, neighbourIDs...)),
|
||||
})
|
||||
}
|
||||
|
||||
// nodeAnchoredAssets resolves the exploration_anchors of the given nodes into
|
||||
// display-ready asset labels, keyed by node id. Anchors are provenance decoration
|
||||
// for the 播报板 — a failure here must not cost the caller its page, so errors are
|
||||
// logged and degrade to "no assets".
|
||||
func (s *Server) nodeAnchoredAssets(t *Task, nodeIDs []int64) map[string][]FindingAssetDTO {
|
||||
out := map[string][]FindingAssetDTO{}
|
||||
anchors, err := t.Store.NodeAssets(nodeIDs)
|
||||
if err != nil {
|
||||
log.Printf("[broadcast] node assets: %v", err)
|
||||
return out
|
||||
}
|
||||
var flat []int64
|
||||
for _, ids := range anchors {
|
||||
flat = append(flat, ids...)
|
||||
}
|
||||
assets := s.resolveAssetIDs(flat)
|
||||
for nodeID, ids := range anchors {
|
||||
if dtos := findingAssetDTOs(ids, assets); len(dtos) > 0 {
|
||||
out[i64s(nodeID)] = dtos
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// csvValues splits a comma-separated query parameter, dropping empty entries.
|
||||
func csvValues(s string) []string {
|
||||
out := []string{}
|
||||
|
||||
@@ -24,12 +24,14 @@ import {
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { api } from "@/lib/api";
|
||||
import { type Tone, toneClasses, toneDot } from "@/lib/status";
|
||||
import type { Edge, ExploreKind, TaskNode } from "@/lib/types";
|
||||
import { taskAssetTypeLabel } from "@/lib/task-assets";
|
||||
import type { Edge, ExploreKind, FindingAsset, NewAssetType, TaskNode } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PAGE_SIZES = [20, 50, 100];
|
||||
@@ -207,15 +209,79 @@ function KindChip({ kind }: { kind: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 节点锚定的资产:类型标签 + 可辨识文本。数据随播报页一起下发(node id → 资产),
|
||||
// 展开时直接展示,不额外请求。
|
||||
function AssetList({ assets, dense = false }: { assets: FindingAsset[]; dense?: boolean }) {
|
||||
if (assets.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs font-medium text-muted-foreground">涉及资产 · {assets.length}</div>
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{assets.map((a) => {
|
||||
// 运行时 a.type 可能是标签表未覆盖的类型,退回原始字符串。转一层类型让回退不被判成多余。
|
||||
const typeLabel =
|
||||
(taskAssetTypeLabel as (t: NewAssetType) => string | undefined)(a.type as NewAssetType) || a.type;
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
className={cn(
|
||||
"inline-flex max-w-full items-center gap-1.5 rounded-md border bg-background px-2 py-0.5",
|
||||
dense && "text-xs",
|
||||
)}
|
||||
title={`${typeLabel} · ${a.label}`}
|
||||
>
|
||||
<span className="shrink-0 rounded bg-muted px-1 text-[10px] text-muted-foreground">{typeLabel}</span>
|
||||
<code className="truncate font-mono text-xs">{a.label}</code>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 悬停在上下游条目上时弹出的节点名片:类型/状态/来源/时间 + 摘要 + payload 片段 + 涉及资产。
|
||||
// 数据来自本页已经拿到的 refs,不额外发请求——播报接口已经把邻居节点整份带回来了。
|
||||
function RelatedNodeCard({ node, assets }: { node: TaskNode; assets: FindingAsset[] }) {
|
||||
const kind = viewKind(node);
|
||||
const meta = KIND_META[kind] ?? KIND_META.fact;
|
||||
const ts = Date.parse(node.ts);
|
||||
const summary = summaryOf(node);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<KindChip kind={kind} />
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">#{node.id}</code>
|
||||
<NodeStateBadge node={node} />
|
||||
{node.priority > 0 && (kind === "goal" || kind === "intent") && (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">P{node.priority}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span>类型 {meta.label}</span>
|
||||
<span>来源 {node.origin || "system"}</span>
|
||||
<span>{Number.isNaN(ts) ? node.ts : new Date(ts).toLocaleString("zh-CN")}</span>
|
||||
</div>
|
||||
<p className="line-clamp-4 text-xs break-words">{summary || "(无摘要)"}</p>
|
||||
<AssetList assets={assets} dense />
|
||||
<pre className="max-h-40 overflow-auto rounded border bg-muted/40 p-2 font-mono text-[11px] whitespace-pre-wrap">
|
||||
{prettyPayload(node.payload)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 一条播报涉及的上下游:上游 = 指向本节点的边,下游 = 本节点指出去的边。
|
||||
function RelatedList({
|
||||
title,
|
||||
rows,
|
||||
refs,
|
||||
assets,
|
||||
}: {
|
||||
title: string;
|
||||
rows: Array<{ rel: string; id: string }>;
|
||||
refs: Record<string, TaskNode>;
|
||||
assets: Record<string, FindingAsset[]>;
|
||||
}) {
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
@@ -230,12 +296,20 @@ function RelatedList({
|
||||
{REL_LABEL[row.rel] ?? row.rel}
|
||||
</span>
|
||||
{node ? (
|
||||
<>
|
||||
<KindChip kind={viewKind(node)} />
|
||||
<span className="truncate" title={summaryOf(node)}>
|
||||
{summaryOf(node) || `节点 #${node.id}`}
|
||||
</span>
|
||||
</>
|
||||
<HoverCard openDelay={150} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 cursor-help items-center gap-2 text-left hover:underline"
|
||||
>
|
||||
<KindChip kind={viewKind(node)} />
|
||||
<span className="truncate">{summaryOf(node) || `节点 #${node.id}`}</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-96">
|
||||
<RelatedNodeCard node={node} assets={assets[node.id] ?? []} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
<span className="text-muted-foreground">节点 #{row.id}</span>
|
||||
)}
|
||||
@@ -251,12 +325,14 @@ function BroadcastRow({
|
||||
node,
|
||||
edges,
|
||||
refs,
|
||||
assets,
|
||||
now,
|
||||
fresh,
|
||||
}: {
|
||||
node: TaskNode;
|
||||
edges: Edge[];
|
||||
refs: Record<string, TaskNode>;
|
||||
assets: Record<string, FindingAsset[]>;
|
||||
now: number;
|
||||
fresh: boolean;
|
||||
}) {
|
||||
@@ -303,9 +379,7 @@ function BroadcastRow({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<KindChip kind={kind} />
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
|
||||
#{node.id}
|
||||
</code>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">#{node.id}</code>
|
||||
<NodeStateBadge node={node} />
|
||||
{node.priority > 0 && (kind === "goal" || kind === "intent") && (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">P{node.priority}</span>
|
||||
@@ -331,10 +405,11 @@ function BroadcastRow({
|
||||
<span>来源 {node.origin || "system"}</span>
|
||||
<span>{Number.isNaN(ts) ? node.ts : new Date(ts).toLocaleString("zh-CN")}</span>
|
||||
</div>
|
||||
<AssetList assets={assets[node.id] ?? []} />
|
||||
{(upstream.length > 0 || downstream.length > 0) && (
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<RelatedList title="上游 · 由此而来" rows={upstream} refs={refs} />
|
||||
<RelatedList title="下游 · 由此产生" rows={downstream} refs={refs} />
|
||||
<RelatedList title="上游 · 由此而来" rows={upstream} refs={refs} assets={assets} />
|
||||
<RelatedList title="下游 · 由此产生" rows={downstream} refs={refs} assets={assets} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
@@ -362,6 +437,7 @@ export function BroadcastTab({ taskId }: { taskId: string }) {
|
||||
const [items, setItems] = React.useState<TaskNode[]>([]);
|
||||
const [edges, setEdges] = React.useState<Edge[]>([]);
|
||||
const [refs, setRefs] = React.useState<Record<string, TaskNode>>({});
|
||||
const [assets, setAssets] = React.useState<Record<string, FindingAsset[]>>({});
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [freshIDs, setFreshIDs] = React.useState<Set<string>>(new Set());
|
||||
@@ -413,6 +489,7 @@ export function BroadcastTab({ taskId }: { taskId: string }) {
|
||||
setItems(r.items);
|
||||
setEdges(r.edges);
|
||||
setRefs(r.refs);
|
||||
setAssets(r.assets);
|
||||
}
|
||||
setTotal(r.total);
|
||||
if (atLive) {
|
||||
@@ -454,6 +531,14 @@ export function BroadcastTab({ taskId }: { taskId: string }) {
|
||||
setPending(0);
|
||||
};
|
||||
|
||||
// 服务端的 refs 只补「不在本页的邻居」,同页节点之间的引用要靠 items 自己兜底,
|
||||
// 否则相邻两条播报互相引用时会退化成光秃秃的「节点 #id」。
|
||||
const nodeIndex = React.useMemo(() => {
|
||||
const idx: Record<string, TaskNode> = { ...refs };
|
||||
for (const n of items) idx[n.id] = n;
|
||||
return idx;
|
||||
}, [refs, items]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / size));
|
||||
const start = total === 0 ? 0 : (page - 1) * size + 1;
|
||||
const end = (page - 1) * size + items.length;
|
||||
@@ -579,7 +664,8 @@ export function BroadcastTab({ taskId }: { taskId: string }) {
|
||||
key={node.id}
|
||||
node={node}
|
||||
edges={edges}
|
||||
refs={refs}
|
||||
refs={nodeIndex}
|
||||
assets={assets}
|
||||
now={now}
|
||||
fresh={freshIDs.has(node.id)}
|
||||
/>
|
||||
|
||||
@@ -674,6 +674,7 @@ export const api = {
|
||||
size: r.size ?? query.size ?? 20,
|
||||
edges: arr(r.edges),
|
||||
refs: r.refs ?? {},
|
||||
assets: r.assets ?? {},
|
||||
}));
|
||||
},
|
||||
activity: (task?: string, opts?: { intent?: string; since?: number; limit?: number }) => {
|
||||
|
||||
+74
-16
@@ -3060,8 +3060,17 @@ export const llmConfig = {
|
||||
// ── Agents ───────────────────────────────────────────────────────────────────
|
||||
export const agents: Agent[] = [
|
||||
{
|
||||
id: "1001", key: "retester", name: "漏洞复测", role: "assistant", builtin: false, enabled: true,
|
||||
description: "从漏洞详情手动启动,保存独立复测结论", max_turns: 0, mcp_count: 0, skill_count: 0, tool_count: 2,
|
||||
id: "1001",
|
||||
key: "retester",
|
||||
name: "漏洞复测",
|
||||
role: "assistant",
|
||||
builtin: false,
|
||||
enabled: true,
|
||||
description: "从漏洞详情手动启动,保存独立复测结论",
|
||||
max_turns: 0,
|
||||
mcp_count: 0,
|
||||
skill_count: 0,
|
||||
tool_count: 2,
|
||||
},
|
||||
{
|
||||
id: "1",
|
||||
@@ -3478,35 +3487,65 @@ export const interceptHistory: InterceptApprovalRow[] = [
|
||||
|
||||
// Approval detail fixtures use harmless report-writing examples.
|
||||
interceptHistory.unshift({
|
||||
id: 94, task_id: "t-acme-web", agent_name: "work#1", tool_name: "Write",
|
||||
id: 94,
|
||||
task_id: "t-acme-web",
|
||||
agent_name: "work#1",
|
||||
tool_name: "Write",
|
||||
tool_input: { path: "reports/summary.md", content: "# 检查摘要\n\n本轮验证已完成,整理已有证据与后续建议。" },
|
||||
status: "allowed", decision_source: "model", reason: "[模型] 将已有检查结论写入本地报告,不修改业务数据。",
|
||||
created_at: T("2026-07-26T08:00:00Z"), decided_at: T("2026-07-26T08:00:02Z"),
|
||||
conv_title: "", conv_agent_key: "", rule_name: "",
|
||||
status: "allowed",
|
||||
decision_source: "model",
|
||||
reason: "[模型] 将已有检查结论写入本地报告,不修改业务数据。",
|
||||
created_at: T("2026-07-26T08:00:00Z"),
|
||||
decided_at: T("2026-07-26T08:00:02Z"),
|
||||
conv_title: "",
|
||||
conv_agent_key: "",
|
||||
rule_name: "",
|
||||
});
|
||||
|
||||
export const interceptDetails: Record<number, InterceptAudit> = {
|
||||
94: {
|
||||
run_id: "demo-run-report", tool_use_id: "call-write-report", correlation: "exact",
|
||||
run_id: "demo-run-report",
|
||||
tool_use_id: "call-write-report",
|
||||
correlation: "exact",
|
||||
input_digest: "a3b458eca3b458eca3b458eca3b458eca3b458eca3b458eca3b458eca3b458ec1234",
|
||||
user_message: "请整理已经完成的检查,将结论和证据索引写入 reports/summary.md。\n保留待验证项,不修改业务数据。",
|
||||
context: [
|
||||
{ kind: "user", text: "汇总本轮已有证据,生成检查摘要。" },
|
||||
{ kind: "tool_use", tool: "Read", tool_use_id: "call-read-evidence", text: '{"path":"reports/evidence-index.json"}' },
|
||||
{ kind: "tool_result", tool: "Read", tool_use_id: "call-read-evidence", text: "已读取 3 条证据索引。\n记录包含请求时间、结果摘要和本地文件位置。" },
|
||||
{
|
||||
kind: "tool_use",
|
||||
tool: "Read",
|
||||
tool_use_id: "call-read-evidence",
|
||||
text: '{"path":"reports/evidence-index.json"}',
|
||||
},
|
||||
{
|
||||
kind: "tool_result",
|
||||
tool: "Read",
|
||||
tool_use_id: "call-read-evidence",
|
||||
text: "已读取 3 条证据索引。\n记录包含请求时间、结果摘要和本地文件位置。",
|
||||
},
|
||||
],
|
||||
captured_at: T("2026-07-26T08:00:00Z"), initial_action: "allow",
|
||||
initial_reason: "[模型] 将已有检查结论写入本地报告,不修改业务数据。", effective_action: "allow",
|
||||
captured_at: T("2026-07-26T08:00:00Z"),
|
||||
initial_action: "allow",
|
||||
initial_reason: "[模型] 将已有检查结论写入本地报告,不修改业务数据。",
|
||||
effective_action: "allow",
|
||||
config_digest: "b4c569fdb4c569fdb4c569fdb4c569fdb4c569fdb4c569fdb4c569fdb4c569fd1234",
|
||||
profile_id: 1, execution_status: "succeeded", output: "Successfully wrote reports/summary.md\n共写入 68 个字符。",
|
||||
profile_id: 1,
|
||||
execution_status: "succeeded",
|
||||
output: "Successfully wrote reports/summary.md\n共写入 68 个字符。",
|
||||
execution_ended_at: T("2026-07-26T08:00:03Z"),
|
||||
},
|
||||
93: {
|
||||
run_id: "demo-run-review", tool_use_id: "call-pending-review", correlation: "exact",
|
||||
run_id: "demo-run-review",
|
||||
tool_use_id: "call-pending-review",
|
||||
correlation: "exact",
|
||||
input_digest: "c5d670aec5d670aec5d670aec5d670aec5d670aec5d670aec5d670aec5d670ae1234",
|
||||
user_message: "检查备份表的用途,涉及删除时需要先确认。", context: [],
|
||||
captured_at: T("2026-07-25T15:14:00Z"), initial_action: "ask",
|
||||
initial_reason: "[模型] 疑似备份表,无法确定是否生产数据。", profile_id: 1, execution_status: "not_started",
|
||||
user_message: "检查备份表的用途,涉及删除时需要先确认。",
|
||||
context: [],
|
||||
captured_at: T("2026-07-25T15:14:00Z"),
|
||||
initial_action: "ask",
|
||||
initial_reason: "[模型] 疑似备份表,无法确定是否生产数据。",
|
||||
profile_id: 1,
|
||||
execution_status: "not_started",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3897,6 +3936,25 @@ export const coverageGraph = {
|
||||
};
|
||||
|
||||
// ── 资产在本任务关联的意图/事实/发现(/tasks/{id}/asset-refs)──
|
||||
// 播报板 demo:探索节点 → 其锚定资产。真实后端读 exploration_anchors,mock 里静态给几条。
|
||||
const NODE_ASSETS: Record<string, number[]> = {
|
||||
fi1: [3],
|
||||
fi2: [2, 4],
|
||||
fi3: [5],
|
||||
fiRce: [4],
|
||||
fiPriv: [19],
|
||||
fiJenkins: [21],
|
||||
i1: [2],
|
||||
i7: [4],
|
||||
};
|
||||
|
||||
export function nodeAssetsFor(nodeId: string): FindingAsset[] {
|
||||
return (NODE_ASSETS[nodeId] ?? []).flatMap((id) => {
|
||||
const asset = assets.find((candidate) => candidate.id === id);
|
||||
return asset ? [assetRef(id)] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function assetRefsFor(_assetId: number) {
|
||||
return {
|
||||
intents: [
|
||||
|
||||
@@ -2172,7 +2172,13 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (node) refs[id] = node;
|
||||
}
|
||||
}
|
||||
return { items, total: matched.length, page, size, edges, refs };
|
||||
// 顺带带上本页节点(含邻居)锚定的资产,供展开时展示。
|
||||
const assets: Record<string, ReturnType<typeof D.nodeAssetsFor>> = {};
|
||||
for (const id of new Set([...onPage, ...Object.keys(refs)])) {
|
||||
const anchored = D.nodeAssetsFor(id);
|
||||
if (anchored.length > 0) assets[id] = anchored;
|
||||
}
|
||||
return { items, total: matched.length, page, size, edges, refs, assets };
|
||||
}
|
||||
if (path === "/exploration/activity" && seg.length === 2) {
|
||||
const since = Number(q.get("since") ?? 0);
|
||||
|
||||
@@ -429,6 +429,8 @@ export interface ExplorationNodePage {
|
||||
size: number;
|
||||
edges: Edge[];
|
||||
refs: Record<string, TaskNode>;
|
||||
// 节点 id → 该节点锚定的资产(播报板展开时顺带展示,含本页节点与其邻居)。
|
||||
assets: Record<string, FindingAsset[]>;
|
||||
}
|
||||
|
||||
export interface ExplorationNodeQuery {
|
||||
|
||||
Reference in new issue
Block a user