diff --git a/db/exploration.go b/db/exploration.go
index 4ec4f30..4e679c5 100644
--- a/db/exploration.go
+++ b/db/exploration.go
@@ -615,6 +615,51 @@ AND (summary ILIKE $2 OR detail ILIKE $2) ORDER BY id LIMIT $3`, s.expID, like,
return scanTrace(rows)
}
+// AssetRef is a compact exploration node (intent/fact/finding) that references an
+// asset via an anchor — for the coverage graph's node drawer.
+type AssetRef struct {
+ ID int64 `json:"id"`
+ Kind string `json:"kind"`
+ State string `json:"state"`
+ Summary string `json:"summary"`
+}
+
+// AssetRefs returns the intents / facts / findings in THIS exploration anchored to
+// the given asset id (newest first) — what this task tested / concluded about it.
+func (s *ExplorationStore) AssetRefs(assetID int64) ([]AssetRef, error) {
+ if assetID <= 0 {
+ return []AssetRef{}, nil
+ }
+ rows, err := s.db.Query(`
+SELECT en.id, en.kind, en.state, en.payload
+FROM exploration_anchors ea
+JOIN exploration_nodes en ON en.id = ea.node_id
+WHERE ea.asset_id = $1 AND en.exploration_id = $2 AND en.kind IN ('intent','fact','finding')
+ORDER BY en.id DESC`, assetID, s.expID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []AssetRef{}
+ for rows.Next() {
+ var r AssetRef
+ var payload []byte
+ if err := rows.Scan(&r.ID, &r.Kind, &r.State, &payload); err != nil {
+ return nil, err
+ }
+ var p map[string]any
+ _ = json.Unmarshal(payload, &p)
+ for _, k := range []string{"summary", "text"} {
+ if v, ok := p[k].(string); ok && strings.TrimSpace(v) != "" {
+ r.Summary = v
+ break
+ }
+ }
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+
// ActivityTraceSearchExcluding keyword-searches every node's steps in this
// exploration EXCEPT one node's own (excludeNodeID) — so a worker's
// search_all_worker_traces doesn't return its own in-progress trace (which is
diff --git a/server/server.go b/server/server.go
index 7c7fde9..73773f2 100644
--- a/server/server.go
+++ b/server/server.go
@@ -484,6 +484,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/tasks/{id}", s.getTask)
mux.HandleFunc("GET /api/tasks/{id}/coverage", s.taskCoverage)
mux.HandleFunc("GET /api/tasks/{id}/coverage-graph", s.taskCoverageGraph)
+ mux.HandleFunc("GET /api/tasks/{id}/asset-refs", s.taskAssetRefs)
mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList)
mux.HandleFunc("POST /api/tasks/{id}/control", s.control)
mux.HandleFunc("POST /api/active", s.setActive)
@@ -1130,6 +1131,40 @@ func (s *Server) taskCoverageGraph(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, g)
}
+// taskAssetRefs returns the intents / facts / findings in this task anchored to a
+// given asset id — powers the coverage-graph node drawer's「关联意图 / 关联事实」。
+func (s *Server) taskAssetRefs(w http.ResponseWriter, r *http.Request) {
+ t, ok := s.m.Task(r.PathValue("id"))
+ if !ok {
+ writeErr(w, 404, "task not found")
+ return
+ }
+ assetID, _ := strconv.ParseInt(r.URL.Query().Get("asset_id"), 10, 64)
+ if assetID <= 0 {
+ writeErr(w, 400, "需要 asset_id")
+ return
+ }
+ refs, err := t.Store.AssetRefs(assetID)
+ if err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ intents := []db.AssetRef{}
+ facts := []db.AssetRef{}
+ findings := []db.AssetRef{}
+ for _, ref := range refs {
+ switch ref.Kind {
+ case "intent":
+ intents = append(intents, ref)
+ case "fact":
+ facts = append(facts, ref)
+ case "finding":
+ findings = append(findings, ref)
+ }
+ }
+ writeJSON(w, 200, map[string]any{"intents": intents, "facts": facts, "findings": findings})
+}
+
// taskScopeList returns a task's scope rows (coverage denominator sources).
func (s *Server) taskScopeList(w http.ResponseWriter, r *http.Request) {
t, ok := s.m.Task(r.PathValue("id"))
diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx
index 17ba930..0974489 100644
--- a/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx
+++ b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx
@@ -25,7 +25,7 @@ import {
SheetTitle,
} from "@/components/ui/sheet";
import { api } from "@/lib/api";
-import type { CoverageGraphEdge, CoverageGraphNode } from "@/lib/types";
+import type { CoverageAssetRef, CoverageAssetRefs, CoverageGraphEdge, CoverageGraphNode } from "@/lib/types";
// 每个父节点下、同一类型的子节点默认展示的数量;超出折叠,"展示更多"每次再拉这么多。
const FOLD_LIMIT = 20;
@@ -33,18 +33,58 @@ const FOLD_STEP = 20;
type Kind = CoverageGraphNode["kind"];
-type KindMeta = { label: string; icon: LucideIcon; iconBg: string; hex: string; emoji: string; size: number };
+type KindMeta = { label: string; icon: LucideIcon; iconBg: string; hex: string; size: number };
const kindMeta: Record = {
- company: { label: "公司", icon: Building2, iconBg: "bg-slate-500", hex: "#64748b", emoji: "🏢", size: 46 },
- root_domain: { label: "根域名", icon: Globe, iconBg: "bg-indigo-500", hex: "#6366f1", emoji: "🌐", size: 38 },
- subdomain: { label: "子域名", icon: Waypoints, iconBg: "bg-blue-500", hex: "#3b82f6", emoji: "🔗", size: 30 },
- ip: { label: "IP", icon: Server, iconBg: "bg-cyan-600", hex: "#0891b2", emoji: "🖥️", size: 28 },
- service: { label: "服务", icon: Radio, iconBg: "bg-amber-500", hex: "#f59e0b", emoji: "📡", size: 26 },
- app: { label: "App", icon: AppWindow, iconBg: "bg-fuchsia-500", hex: "#d946ef", emoji: "📱", size: 26 },
- endpoint: { label: "端点", icon: Link2, iconBg: "bg-rose-500", hex: "#f43f5e", emoji: "🔌", size: 20 },
+ company: { label: "公司", icon: Building2, iconBg: "bg-slate-500", hex: "#64748b", size: 46 },
+ root_domain: { label: "根域名", icon: Globe, iconBg: "bg-indigo-500", hex: "#6366f1", size: 38 },
+ subdomain: { label: "子域名", icon: Waypoints, iconBg: "bg-blue-500", hex: "#3b82f6", size: 30 },
+ ip: { label: "IP", icon: Server, iconBg: "bg-cyan-600", hex: "#0891b2", size: 28 },
+ service: { label: "服务", icon: Radio, iconBg: "bg-amber-500", hex: "#f59e0b", size: 26 },
+ app: { label: "App", icon: AppWindow, iconBg: "bg-fuchsia-500", hex: "#d946ef", size: 26 },
+ endpoint: { label: "端点", icon: Link2, iconBg: "bg-rose-500", hex: "#f43f5e", size: 20 },
};
+// G6 节点图标用平台一致的 lucide 图标:把 lucide 的 SVG 路径(v1.22)渲染成白色描边的
+// data URI,作为节点 iconSrc(白色在实色/灰色底上都清晰)。手写内嵌,避免 react-dom/server
+// 在 React19/Next 客户端打包的问题。
+function svgUri(inner: string, filled = false): string {
+ const attrs = filled
+ ? 'fill="#fff" stroke="none"'
+ : 'fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
+ const svg = ``;
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
+}
+
+const KIND_ICON: Record = {
+ company: svgUri(
+ '',
+ ),
+ root_domain: svgUri(
+ '',
+ ),
+ subdomain: svgUri(
+ '',
+ ),
+ ip: svgUri(
+ '',
+ ),
+ service: svgUri(
+ '',
+ ),
+ app: svgUri(
+ '',
+ ),
+ endpoint: svgUri(
+ '',
+ ),
+};
+
+const FOLD_ICON = svgUri(
+ '',
+ true,
+);
+
// ---------------------------------------------------------------------------
// Folding: full graph → currently-visible node/edge set (自顶向下级联折叠)。
// ---------------------------------------------------------------------------
@@ -150,6 +190,26 @@ function trunc(s: string, n = 26): string {
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
}
+// 图里的节点文案:服务不显示完整 URL,只显示 端口·标题·状态码;端点只显示 path。
+// 其余类型沿用后端给的 label。
+function graphLabel(n: CoverageGraphNode): string {
+ if (n.kind === "service") {
+ const parts: string[] = [];
+ if (n.port) parts.push(`:${n.port}`);
+ if (n.page_title) parts.push(n.page_title);
+ if (n.status_code) parts.push(String(n.status_code));
+ return parts.length ? parts.join(" · ") : n.domain || n.ip || n.label;
+ }
+ if (n.kind === "endpoint" && n.url) {
+ try {
+ return new URL(n.url).pathname || "/";
+ } catch {
+ /* 非法 URL:回退到完整 label */
+ }
+ }
+ return n.label;
+}
+
function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] {
return renderNodes.map((rn) => {
if (rn.fold) {
@@ -169,7 +229,7 @@ function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] {
fold: false,
tested: rn.node.tested,
inScope: rn.node.in_scope,
- lbl: trunc(rn.node.label || kindMeta[rn.kind].label),
+ lbl: trunc(graphLabel(rn.node) || kindMeta[rn.kind].label),
size: kindMeta[rn.kind].size,
};
});
@@ -205,16 +265,56 @@ function DetailRow({ label, children }: { label: string; children: React.ReactNo
);
}
+function RefList({ title, items }: { title: string; items: CoverageAssetRef[] }) {
+ if (items.length === 0) return null;
+ return (
+
+
+ {title}({items.length})
+
+
+ {items.map((r) => (
+
+ #{r.id}
+ {r.state && {r.state}}
+ {r.summary || "—"}
+
+ ))}
+
+
+ );
+}
+
function AssetSheet({
node,
+ taskId,
onOpenChange,
}: {
node: CoverageGraphNode | null;
+ taskId: string;
onOpenChange: (open: boolean) => void;
}) {
const meta = node ? kindMeta[node.kind] : null;
const Icon = meta?.icon;
const raw = node ? JSON.stringify(node, null, 2) : "";
+ const [refs, setRefs] = React.useState(null);
+
+ React.useEffect(() => {
+ setRefs(null);
+ if (!node?.asset_id) return;
+ let cancelled = false;
+ api
+ .taskAssetRefs(taskId, node.asset_id)
+ .then((r) => {
+ if (!cancelled) setRefs(r);
+ })
+ .catch(() => {
+ /* 无关联或出错:不展示该区块 */
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [node, taskId]);
return (
@@ -263,6 +363,13 @@ function AssetSheet({
{node.asset_id ? {node.asset_id} : undefined}
+ {refs && (refs.intents.length > 0 || refs.facts.length > 0 || refs.findings.length > 0) && (
+
+ )}
原始数据
@@ -424,8 +531,9 @@ function GraphInner({ taskId }: { taskId: string }) {
stroke: (d: unknown) => nodeStroke(nd(d)),
lineWidth: (d: unknown) => (nd(d).fold || !nd(d).inScope ? 1 : 1.5),
lineDash: (d: unknown) => (nd(d).fold || !nd(d).inScope ? [3, 3] : [0]),
- iconText: (d: unknown) => (nd(d).fold ? "⋯" : kindMeta[nd(d).kind].emoji),
- iconFontSize: (d: unknown) => Math.max(11, nd(d).size * 0.48),
+ iconSrc: (d: unknown) => (nd(d).fold ? FOLD_ICON : KIND_ICON[nd(d).kind]),
+ iconWidth: (d: unknown) => Math.max(12, nd(d).size * 0.55),
+ iconHeight: (d: unknown) => Math.max(12, nd(d).size * 0.55),
labelText: (d: unknown) => nd(d).lbl,
labelFontSize: 10,
labelPlacement: "bottom",
@@ -551,7 +659,7 @@ function GraphInner({ taskId }: { taskId: string }) {
- !o && setSelectedAsset(null)} />
+ !o && setSelectedAsset(null)} />
!o && setSelectedFoldId(null)}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 21e198f..e1f5b1d 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -11,7 +11,7 @@ import type {
PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket,
Tool, Conversation, AgentTrigger,
InterceptRule, InterceptPending, InterceptApprovalRow,
- TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData,
+ TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs,
CommandRecord, LLMRecordItem, LLMRecordDetail,
} from "@/lib/types";
@@ -127,6 +127,9 @@ export const api = {
// 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。
taskCoverageGraph: (id: string) =>
get(`/tasks/${id}/coverage-graph`),
+ // 某资产在本任务里关联的意图 / 事实 / 发现(覆盖图节点抽屉用)。
+ taskAssetRefs: (id: string, assetId: number) =>
+ get(`/tasks/${id}/asset-refs?asset_id=${assetId}`),
// ---- assets ----
// Server-side paginated: pass limit/offset, get back the page + full match total.
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index f4f299d..347d115 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -165,6 +165,19 @@ export interface CoverageGraphData {
edges: CoverageGraphEdge[];
}
+// 某资产在本任务探索图里关联到的意图/事实/发现(覆盖图节点抽屉用)。
+export interface CoverageAssetRef {
+ id: number;
+ kind: string;
+ state: string;
+ summary: string;
+}
+export interface CoverageAssetRefs {
+ intents: CoverageAssetRef[];
+ facts: CoverageAssetRef[];
+ findings: CoverageAssetRef[];
+}
+
// 公司资产范围规则的一条(归属唯一真值来源)。
export interface ScopeRow {
id: number;