From d95f0d002d51a7728b57b0a7936d56aaeb89dcaa Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:23:53 -0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=92=AD=E6=8A=A5=E6=9D=BF?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E8=AF=A6=E6=83=85=E5=B1=95=E7=A4=BA=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=E8=8A=82=E7=82=B9=E4=BF=A1=E6=81=AF=E4=B8=8E=E9=94=9A?= =?UTF-8?q?=E5=AE=9A=E8=B5=84=E4=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游/下游节点从只显示 id 改为悬停弹出节点名片(类型/状态/来源/时间/摘要/payload); 展开时顺带展示该节点锚定的资产(类型标签 + 可辨识文本)。 服务端 /exploration/nodes 新增 assets 字段(节点id→资产),读 exploration_anchors 并复用发现列表的资产打标逻辑;同页节点互引时用本页 items 兜底 refs 查找表。 --- server/server.go | 37 +++++- .../tasks/detail/_tabs/broadcast-tab.tsx | 112 ++++++++++++++++-- web/src/lib/api.ts | 1 + web/src/lib/mock/data.ts | 90 +++++++++++--- web/src/lib/mock/handler.ts | 8 +- web/src/lib/types.ts | 2 + 6 files changed, 214 insertions(+), 36 deletions(-) diff --git a/server/server.go b/server/server.go index c52eba5..6043523 100644 --- a/server/server.go +++ b/server/server.go @@ -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{} diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/broadcast-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/broadcast-tab.tsx index 966174c..8b9fd12 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/broadcast-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/broadcast-tab.tsx @@ -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 ( +
+
涉及资产 · {assets.length}
+ +
+ ); +} + +// 悬停在上下游条目上时弹出的节点名片:类型/状态/来源/时间 + 摘要 + 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 ( +
+
+ + #{node.id} + + {node.priority > 0 && (kind === "goal" || kind === "intent") && ( + P{node.priority} + )} +
+
+ 类型 {meta.label} + 来源 {node.origin || "system"} + {Number.isNaN(ts) ? node.ts : new Date(ts).toLocaleString("zh-CN")} +
+

{summary || "(无摘要)"}

+ +
+        {prettyPayload(node.payload)}
+      
+
+ ); +} + // 一条播报涉及的上下游:上游 = 指向本节点的边,下游 = 本节点指出去的边。 function RelatedList({ title, rows, refs, + assets, }: { title: string; rows: Array<{ rel: string; id: string }>; refs: Record; + assets: Record; }) { if (rows.length === 0) return null; return ( @@ -230,12 +296,20 @@ function RelatedList({ {REL_LABEL[row.rel] ?? row.rel} {node ? ( - <> - - - {summaryOf(node) || `节点 #${node.id}`} - - + + + + + + + + ) : ( 节点 #{row.id} )} @@ -251,12 +325,14 @@ function BroadcastRow({ node, edges, refs, + assets, now, fresh, }: { node: TaskNode; edges: Edge[]; refs: Record; + assets: Record; now: number; fresh: boolean; }) { @@ -303,9 +379,7 @@ function BroadcastRow({
- - #{node.id} - + #{node.id} {node.priority > 0 && (kind === "goal" || kind === "intent") && ( P{node.priority} @@ -331,10 +405,11 @@ function BroadcastRow({ 来源 {node.origin || "system"} {Number.isNaN(ts) ? node.ts : new Date(ts).toLocaleString("zh-CN")}
+ {(upstream.length > 0 || downstream.length > 0) && (
- - + +
)}
@@ -362,6 +437,7 @@ export function BroadcastTab({ taskId }: { taskId: string }) { const [items, setItems] = React.useState([]); const [edges, setEdges] = React.useState([]); const [refs, setRefs] = React.useState>({}); + const [assets, setAssets] = React.useState>({}); const [total, setTotal] = React.useState(0); const [loaded, setLoaded] = React.useState(false); const [freshIDs, setFreshIDs] = React.useState>(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 = { ...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)} /> diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 73252b0..cea3e80 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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 }) => { diff --git a/web/src/lib/mock/data.ts b/web/src/lib/mock/data.ts index 9332261..6eaa336 100644 --- a/web/src/lib/mock/data.ts +++ b/web/src/lib/mock/data.ts @@ -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 = { 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 = { + 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: [ diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index ded2546..affac15 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -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> = {}; + 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); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 71219f8..d59edc7 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -429,6 +429,8 @@ export interface ExplorationNodePage { size: number; edges: Edge[]; refs: Record; + // 节点 id → 该节点锚定的资产(播报板展开时顺带展示,含本页节点与其邻居)。 + assets: Record; } export interface ExplorationNodeQuery {