diff --git a/db/exploration.go b/db/exploration.go index 5be7a45..c7f4228 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -785,6 +785,129 @@ func (s *ExplorationStore) Nodes(limit int) ([]*Node, error) { return scanNodes(rows) } +// NodeFilter narrows a NodesPage query. Empty fields include every value. +type NodeFilter struct { + Kinds []string // exploration_nodes.kind + States []string // exploration_nodes.state + Query string // case-insensitive substring over payload text / origin + Asc bool // true = oldest first (replay); default newest first (broadcast) +} + +// NodesPage returns one 1-based page of this exploration's nodes plus the total +// matching count. The 播报板 reads the graph as a time series, so it pages in SQL +// rather than pulling the whole graph like Nodes does. Ordering is by id, which +// is BIGSERIAL and therefore creation order — stable when several nodes share a +// created_at second. +func (s *ExplorationStore) NodesPage(f NodeFilter, page, size int) ([]*Node, int, error) { + if page < 1 { + page = 1 + } + if size <= 0 { + size = 20 + } + if size > 200 { + size = 200 + } + offset := (page - 1) * size + + conds := []string{"exploration_id=$1"} + args := []any{s.expID} + addIn := func(column string, values []string) { + if len(values) == 0 { + return + } + marks := make([]string, 0, len(values)) + for _, v := range values { + args = append(args, v) + marks = append(marks, "$"+fmt.Sprint(len(args))) + } + conds = append(conds, column+" IN ("+strings.Join(marks, ",")+")") + } + addIn("kind", f.Kinds) + addIn("state", f.States) + if q := strings.TrimSpace(f.Query); q != "" { + args = append(args, "%"+q+"%") + mark := "$" + fmt.Sprint(len(args)) + conds = append(conds, "(payload::text ILIKE "+mark+" OR COALESCE(origin,'') ILIKE "+mark+")") + } + where := " WHERE " + strings.Join(conds, " AND ") + + var total int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM exploration_nodes`+where, args...).Scan(&total); err != nil { + return nil, 0, err + } + order := "DESC" + if f.Asc { + order = "ASC" + } + args = append(args, size, offset) + rows, err := s.db.Query(`SELECT `+nodeCols+` FROM exploration_nodes`+where+ + ` ORDER BY id `+order+ + ` LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + nodes, err := scanNodes(rows) + if err != nil { + return nil, 0, err + } + return nodes, total, nil +} + +// NodesByIDs loads the given nodes of this exploration in id order. Used to +// resolve the neighbours of a 播报板 page without fetching the whole graph. +func (s *ExplorationStore) NodesByIDs(ids []int64) ([]*Node, error) { + if len(ids) == 0 { + return nil, nil + } + args := make([]any, 0, len(ids)+1) + args = append(args, s.expID) + marks := make([]string, 0, len(ids)) + for _, id := range ids { + args = append(args, id) + marks = append(marks, "$"+fmt.Sprint(len(args))) + } + rows, err := s.db.Query(`SELECT `+nodeCols+` FROM exploration_nodes +WHERE exploration_id=$1 AND id IN (`+strings.Join(marks, ",")+`) ORDER BY id`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanNodes(rows) +} + +// EdgesTouching returns every edge with one end among ids — the 播报板 uses it to +// spell out where a node came from and what it produced. +func (s *ExplorationStore) EdgesTouching(ids []int64) ([]Edge, error) { + if len(ids) == 0 { + return nil, nil + } + args := make([]any, 0, len(ids)+1) + args = append(args, s.expID) + marks := make([]string, 0, len(ids)) + for _, id := range ids { + args = append(args, id) + marks = append(marks, "$"+fmt.Sprint(len(args))) + } + list := strings.Join(marks, ",") + rows, err := s.db.Query(`SELECT src_id, rel, dst_id FROM exploration_edges +WHERE exploration_id=$1 AND (src_id IN (`+list+`) OR dst_id IN (`+list+`))`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Edge + for rows.Next() { + var e Edge + if err := rows.Scan(&e.From, &e.Rel, &e.To); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + // Edge is one row from exploration_edges. type Edge struct { From int64 diff --git a/server/server.go b/server/server.go index 92901fb..92c0ed6 100644 --- a/server/server.go +++ b/server/server.go @@ -858,6 +858,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("DELETE /api/exploration/findings/{id}", s.deleteFinding) mux.HandleFunc("GET /api/exploration/intents", s.intents) mux.HandleFunc("GET /api/exploration/graph", s.explorationGraph) + mux.HandleFunc("GET /api/exploration/nodes", s.explorationNodes) mux.HandleFunc("GET /api/exploration/activity", s.activity) mux.HandleFunc("GET /api/exploration/activity/history", s.activityHistory) mux.HandleFunc("GET /api/exploration/main-sessions", s.mainSessions) @@ -2576,6 +2577,90 @@ func (s *Server) explorationGraph(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"nodes": taskNodeDTOs(nodes), "edges": edgeDTOs(edges)}) } +// explorationNodes serves the 播报板: this task's own exploration nodes as a +// paged time series (newest first unless ?order=asc), filterable by kind/state +// and a payload substring. Inherited nodes are deliberately out of scope — the +// board reports what this task is doing right now, and paging across the source +// tasks' stores would make the cursor meaningless. +// Response also carries the edges touching the page plus the neighbour nodes +// they point at, so each row can state where it came from and what it produced. +func (s *Server) explorationNodes(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + page := atoiDefault(q.Get("page"), 1) + size := atoiDefault(q.Get("size"), 20) + t := s.m.ResolveTask(q.Get("task")) + if t == nil { + writeJSON(w, 200, map[string]any{ + "items": []any{}, "total": 0, "page": page, "size": size, + "edges": []any{}, "refs": map[string]any{}, + }) + return + } + filter := db.NodeFilter{ + Kinds: csvValues(q.Get("kind")), + States: csvValues(q.Get("state")), + Query: q.Get("q"), + Asc: q.Get("order") == "asc", + } + nodes, total, err := t.Store.NodesPage(filter, page, size) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + ids := make([]int64, 0, len(nodes)) + onPage := make(map[int64]bool, len(nodes)) + for _, n := range nodes { + ids = append(ids, n.ID) + onPage[n.ID] = true + } + edges, err := t.Store.EdgesTouching(ids) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + neighbourSet := map[int64]bool{} + for _, e := range edges { + if !onPage[e.From] { + neighbourSet[e.From] = true + } + if !onPage[e.To] { + neighbourSet[e.To] = true + } + } + neighbourIDs := make([]int64, 0, len(neighbourSet)) + for id := range neighbourSet { + neighbourIDs = append(neighbourIDs, id) + } + neighbours, err := t.Store.NodesByIDs(neighbourIDs) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + refs := make(map[string]TaskNodeDTO, len(neighbours)) + for _, n := range neighbours { + 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, + }) +} + +// csvValues splits a comma-separated query parameter, dropping empty entries. +func csvValues(s string) []string { + out := []string{} + for _, part := range strings.Split(s, ",") { + if v := strings.TrimSpace(part); v != "" { + out = append(out, v) + } + } + return out +} + // activity returns the worker execution step log (incremental via ?since=seq). func (s *Server) activity(w http.ResponseWriter, r *http.Request) { t := s.m.ResolveTask(r.URL.Query().Get("task")) 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 new file mode 100644 index 0000000..438f57f --- /dev/null +++ b/web/src/app/(main)/function/tasks/detail/_tabs/broadcast-tab.tsx @@ -0,0 +1,639 @@ +"use client"; + +import * as React from "react"; + +import { + ArrowDownIcon, + ArrowUpIcon, + ArrowUpToLineIcon, + BugIcon, + ChevronLeftIcon, + ChevronRightIcon, + CompassIcon, + FlagIcon, + FlaskConicalIcon, + LayersIcon, + LightbulbIcon, + type LucideIcon, + PauseIcon, + PlayIcon, + SearchIcon, + TargetIcon, +} from "lucide-react"; + +import { StatusBadge } from "@/components/status-badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardFooter } from "@/components/ui/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 { cn } from "@/lib/utils"; + +const PAGE_SIZES = [20, 50, 100]; +const POLL_MS = 8000; + +type KindMeta = { label: string; icon: LucideIcon; dot: string; chip: string }; + +// 播报板自己的展示元数据。刻意不复用探索链路图那份:图是拓扑视角(节点卡片、连线配色), +// 播报是流水视角(时间轴行),两边的信息密度和配色需求不同,各自演进更省事。 +const KIND_META: Record = { + begin: { + label: "起点", + icon: FlagIcon, + dot: "bg-slate-500", + chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300", + }, + task: { + label: "根任务", + icon: FlagIcon, + dot: "bg-slate-500", + chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300", + }, + goal: { + label: "目标", + icon: TargetIcon, + dot: "bg-emerald-500", + chip: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400", + }, + intent: { + label: "意图", + icon: CompassIcon, + dot: "bg-blue-500", + chip: "bg-blue-500/15 text-blue-600 dark:text-blue-400", + }, + fact: { + label: "事实", + icon: FlaskConicalIcon, + dot: "bg-amber-500", + chip: "bg-amber-500/15 text-amber-600 dark:text-amber-400", + }, + finding: { + label: "漏洞", + icon: BugIcon, + dot: "bg-rose-500", + chip: "bg-rose-500/15 text-rose-600 dark:text-rose-400", + }, + hint: { + label: "提示", + icon: LightbulbIcon, + dot: "bg-violet-500", + chip: "bg-violet-500/15 text-violet-600 dark:text-violet-400", + }, + digest: { + label: "压缩", + icon: LayersIcon, + dot: "bg-teal-500", + chip: "bg-teal-500/15 text-teal-600 dark:text-teal-400", + }, +}; + +// 可筛选的类型。起点(fact/state=origin)不单独列,它跟着「事实」一起过滤。 +const FILTER_KINDS: ExploreKind[] = ["goal", "intent", "fact", "finding", "hint", "digest"]; + +const REL_LABEL: Record = { + spawns: "派生", + derived_from: "意图链", + yields: "产出", + proves: "证明", + covers: "压缩", +}; + +// goal / intent 的状态语义由全局 status 表提供(StatusBadge);其余类型的状态只在 +// 图和播报里出现,这里补一份。 +const STATE_META: Record> = { + fact: { + origin: { label: "起点", tone: "slate" }, + confirmed: { label: "已确认", tone: "green" }, + dismissed: { label: "已否定", tone: "slate" }, + }, + finding: { + confirmed: { label: "已确认", tone: "red" }, + dismissed: { label: "已排除", tone: "slate" }, + }, + hint: { + active: { label: "待采纳", tone: "violet" }, + consumed: { label: "已采纳", tone: "slate" }, + }, + digest: { + active: { label: "生效中", tone: "green" }, + superseded: { label: "已替代", tone: "slate" }, + }, +}; + +// 任务根是 state=origin 的 fact,播报里读作「起点」。 +function viewKind(n: TaskNode): string { + return n.type === "fact" && n.state === "origin" ? "begin" : n.type; +} + +const SUMMARY_FIELDS: Record = { + begin: ["summary", "description"], + task: ["summary", "description"], + goal: ["text"], + intent: ["summary"], + fact: ["summary"], + finding: ["name", "summary"], + hint: ["text", "summary"], + digest: ["body", "summary"], +}; + +function summaryOf(n: TaskNode): string { + const raw = n.payload ?? ""; + if (!raw.trim()) return ""; + for (const field of SUMMARY_FIELDS[viewKind(n)] ?? []) { + try { + const obj: unknown = JSON.parse(raw); + if (obj && typeof obj === "object") { + const v = (obj as Record)[field]; + if (typeof v === "string" && v.trim()) return v; + } + } catch { + return raw; // 非 JSON payload:原样播报 + } + } + return raw; +} + +function prettyPayload(raw?: string): string { + if (!raw?.trim()) return "(无 payload)"; + try { + return JSON.stringify(JSON.parse(raw), null, 2); + } catch { + return raw; + } +} + +function relTime(ts: number, now: number): string { + if (!now || !ts) return ""; + const sec = Math.max(0, (now - ts) / 1000); + if (sec < 60) return "刚刚"; + if (sec < 3600) return `${Math.floor(sec / 60)} 分钟前`; + if (sec < 86400) return `${Math.floor(sec / 3600)} 小时前`; + return `${Math.floor(sec / 86400)} 天前`; +} + +const dayFmt = new Intl.DateTimeFormat("zh-CN", { month: "long", day: "numeric", weekday: "short" }); +const clockFmt = new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, +}); + +function NodeStateBadge({ node }: { node: TaskNode }) { + if (node.type === "goal") return ; + if (node.type === "intent") return ; + const meta = STATE_META[node.type]?.[node.state]; + if (!meta) return null; + return ( + + + {meta.label} + + ); +} + +function KindChip({ kind }: { kind: string }) { + const meta = KIND_META[kind] ?? KIND_META.fact; + return ( + {meta.label} + ); +} + +// 一条播报涉及的上下游:上游 = 指向本节点的边,下游 = 本节点指出去的边。 +function RelatedList({ + title, + rows, + refs, +}: { + title: string; + rows: Array<{ rel: string; id: string }>; + refs: Record; +}) { + if (rows.length === 0) return null; + return ( +
+
{title}
+
    + {rows.map((row) => { + const node = refs[row.id]; + return ( +
  • + + {REL_LABEL[row.rel] ?? row.rel} + + {node ? ( + <> + + + {summaryOf(node) || `节点 #${node.id}`} + + + ) : ( + 节点 #{row.id} + )} +
  • + ); + })} +
+
+ ); +} + +function BroadcastRow({ + node, + edges, + refs, + now, + fresh, +}: { + node: TaskNode; + edges: Edge[]; + refs: Record; + now: number; + fresh: boolean; +}) { + const [open, setOpen] = React.useState(false); + const kind = viewKind(node); + const meta = KIND_META[kind] ?? KIND_META.fact; + const Icon = meta.icon; + const ts = Date.parse(node.ts); + const summary = summaryOf(node); + const upstream = edges.filter((e) => e.dst === node.id).map((e) => ({ rel: e.rel, id: e.src })); + const downstream = edges.filter((e) => e.src === node.id).map((e) => ({ rel: e.rel, id: e.dst })); + + return ( +
+ {/* 时间列 */} +
+
{Number.isNaN(ts) ? "--:--:--" : clockFmt.format(ts)}
+
{relTime(ts, now)}
+
+ + {/* 时间轴:竖线 + 类型圆点 */} +
+ + + + +
+ + {/* 内容列 */} +
+ + + {open && ( +
+
+ + 节点 #{node.id} + + 类型 {meta.label} + 来源 {node.origin || "system"} + {Number.isNaN(ts) ? node.ts : new Date(ts).toLocaleString("zh-CN")} +
+ {(upstream.length > 0 || downstream.length > 0) && ( +
+ + +
+ )} +
+
payload
+
+                {prettyPayload(node.payload)}
+              
+
+
+ )} +
+
+ ); +} + +export function BroadcastTab({ taskId }: { taskId: string }) { + const [kinds, setKinds] = React.useState([]); + const [queryInput, setQueryInput] = React.useState(""); + const [query, setQuery] = React.useState(""); + const [order, setOrder] = React.useState<"asc" | "desc">("desc"); + const [page, setPage] = React.useState(1); + const [size, setSize] = React.useState(20); + const [live, setLive] = React.useState(true); + + const [items, setItems] = React.useState([]); + const [edges, setEdges] = React.useState([]); + const [refs, setRefs] = React.useState>({}); + const [total, setTotal] = React.useState(0); + const [loaded, setLoaded] = React.useState(false); + const [freshIDs, setFreshIDs] = React.useState>(new Set()); + const [pending, setPending] = React.useState(0); + const [now, setNow] = React.useState(0); + + const seenRef = React.useRef>(new Set()); + const baselineRef = React.useRef(null); + const streamRef = React.useRef(""); + // 只有「最新在前的第 1 页」才是真正的直播位;其余位置轮询只更新未读计数, + // 不动列表,免得翻页/展开时内容在脚下变。 + const atLive = page === 1 && order === "desc"; + + React.useEffect(() => { + setNow(Date.now()); + const t = setInterval(() => setNow(Date.now()), 30000); + return () => clearInterval(t); + }, []); + + // 输入防抖:打字停 300ms 才真正查询,并回到第一页。 + React.useEffect(() => { + const t = setTimeout(() => { + setQuery(queryInput); + setPage(1); + }, 300); + return () => clearTimeout(t); + }, [queryInput]); + + React.useEffect(() => { + let alive = true; + let rendered = false; // 本次查询是否已经把内容渲染出来过 + // 换任务/筛选/排序 = 换了一条播报流:清掉「新」标记和未读基线。翻页不算换流, + // 否则回到最新时就没有未读计数可算了。 + const stream = `${taskId}|${kinds.join(",")}|${query}|${order}`; + if (streamRef.current !== stream) { + streamRef.current = stream; + seenRef.current = new Set(); + baselineRef.current = null; + setPending(0); + } + const load = () => + api + .explorationNodes(taskId, { page, size, kinds, q: query, order }) + .then((r) => { + if (!alive) return; + // 直播位每轮都刷新;其它位置只渲染第一次,之后轮询仅更新未读计数。 + if (atLive || !rendered) { + rendered = true; + setItems(r.items); + setEdges(r.edges); + setRefs(r.refs); + } + setTotal(r.total); + if (atLive) { + const seen = seenRef.current; + setFreshIDs(seen.size === 0 ? new Set() : new Set(r.items.filter((n) => !seen.has(n.id)).map((n) => n.id))); + seenRef.current = new Set(r.items.map((n) => n.id)); + baselineRef.current = r.total; + setPending(0); + } else { + const base = baselineRef.current; + setPending(base === null ? 0 : Math.max(0, r.total - base)); + } + setLoaded(true); + }) + .catch(() => { + // 轮询是尽力而为:保留上一次成功的播报内容,下一轮自动重试。 + }); + void load(); + if (!live) { + return () => { + alive = false; + }; + } + const timer = setInterval(load, POLL_MS); + return () => { + alive = false; + clearInterval(timer); + }; + }, [taskId, page, size, kinds, query, order, live, atLive]); + + const toggleKind = (kind: ExploreKind) => { + setKinds((cur) => (cur.includes(kind) ? cur.filter((k) => k !== kind) : [...cur, kind])); + setPage(1); + }; + + const backToLive = () => { + setPage(1); + setOrder("desc"); + setPending(0); + }; + + 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; + + // 换任务或筛选后条数变少时,把越界的页码收回来。 + React.useEffect(() => { + if (page > pageCount) setPage(pageCount); + }, [page, pageCount]); + + // 按天分组:播报流按日期断行,长任务翻页时还能认出「这是哪天的事」。 + const groups: Array<{ day: string; rows: TaskNode[] }> = []; + for (const node of items) { + const ts = Date.parse(node.ts); + const day = Number.isNaN(ts) ? "未知日期" : dayFmt.format(ts); + const last = groups[groups.length - 1]; + if (last && last.day === day) last.rows.push(node); + else groups.push({ day, rows: [node] }); + } + + return ( + + {/* 工具条 */} +
+
+ + setQueryInput(e.target.value)} + placeholder="搜索播报内容 / 来源" + className="h-8 pl-8" + aria-label="搜索播报" + /> +
+
+ {FILTER_KINDS.map((kind) => { + const meta = KIND_META[kind]; + const active = kinds.includes(kind); + return ( + + ); + })} + {kinds.length > 0 && ( + + )} +
+
+ + +
+
+ + {/* 离开直播位时的未读提示 */} + {!atLive && pending > 0 && ( + + )} + + + {!loaded ? ( +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+ ) : items.length === 0 ? ( +

+ {query || kinds.length > 0 ? "没有符合条件的播报。" : "这个任务还没有产生探索节点。"} +

+ ) : ( + groups.map((group) => ( +
+
{group.day}
+ {group.rows.map((node) => ( + + ))} +
+ )) + )} +
+ + + + + {start}–{end} / {total} + +
+ + + {page} / {pageCount} + + +
+
+
+ ); +} diff --git a/web/src/app/(main)/function/tasks/detail/page.tsx b/web/src/app/(main)/function/tasks/detail/page.tsx index 04e3e34..7b81eb0 100644 --- a/web/src/app/(main)/function/tasks/detail/page.tsx +++ b/web/src/app/(main)/function/tasks/detail/page.tsx @@ -40,6 +40,7 @@ import { api } from "@/lib/api"; import type { LLMProfile, Task } from "@/lib/types"; import { AssetsTab } from "./_tabs/assets-tab"; +import { BroadcastTab } from "./_tabs/broadcast-tab"; import { CoverageGraphTab } from "./_tabs/coverage-graph-tab"; import { FindingsTab } from "./_tabs/findings-tab"; import { GraphTab } from "./_tabs/graph-tab"; @@ -53,6 +54,7 @@ const TABS = [ { value: "sessions", label: "会话" }, { value: "overview", label: "总览" }, { value: "graph", label: "探索链路" }, + { value: "broadcast", label: "播报板" }, { value: "findings", label: "发现" }, { value: "retests", label: "复测" }, { value: "assets", label: "测试资产" }, @@ -424,6 +426,9 @@ function TaskDetailInner() { + + + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ddc00da..73252b0 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -31,6 +31,8 @@ import type { DeleteTaskResult, Edge, EvidenceBodyPreview, + ExplorationNodePage, + ExplorationNodeQuery, Finding, FindingAssetTree, FindingDeepenResponse, @@ -655,6 +657,25 @@ export const api = { .then(arr) .catch(() => [] as ConvTokenSummary[]), explorationGraph: (task?: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/graph${tq(task)}`), + // 播报板:服务端按创建顺序分页的探索节点(默认最新在前)。 + explorationNodes: (task: string, query: ExplorationNodeQuery = {}) => { + const q = new URLSearchParams(); + if (task) q.set("task", task); + q.set("page", String(query.page ?? 1)); + q.set("size", String(query.size ?? 20)); + if (query.kinds?.length) q.set("kind", query.kinds.join(",")); + if (query.states?.length) q.set("state", query.states.join(",")); + if (query.q?.trim()) q.set("q", query.q.trim()); + if (query.order === "asc") q.set("order", "asc"); + return get(`/exploration/nodes?${q.toString()}`).then((r) => ({ + items: arr(r.items), + total: r.total ?? 0, + page: r.page ?? 1, + size: r.size ?? query.size ?? 20, + edges: arr(r.edges), + refs: r.refs ?? {}, + })); + }, activity: (task?: string, opts?: { intent?: string; since?: number; limit?: number }) => { const q = new URLSearchParams(); if (task) q.set("task", task); @@ -742,7 +763,10 @@ export const api = { return r.text(); }, chatMentions: (kind: string, query: string, signal?: AbortSignal, cursor = "") => - http<{ items: ChatMention[]; next_cursor?: string }>(`/chat/mentions?${new URLSearchParams({ kind, q: query, cursor })}`, { signal }), + http<{ items: ChatMention[]; next_cursor?: string }>( + `/chat/mentions?${new URLSearchParams({ kind, q: query, cursor })}`, + { signal }, + ), chat: (message: string, task?: string, attachments?: ChatAttachment[], seg?: number) => post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments, seg }), chatStatus: (taskId: string) => get<{ running: boolean }>(`/tasks/${taskId}/chat/status`), @@ -1073,16 +1097,18 @@ export const api = { interceptDecide: (id: number, decision: "allowed" | "denied") => post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }), interceptExecution: (id: number, conversationId?: number) => - get(`/intercept/history/${id}/execution${conversationId ? `?conversation=${conversationId}` : ""}`), + get( + `/intercept/history/${id}/execution${conversationId ? `?conversation=${conversationId}` : ""}`, + ), interceptDetail: (id: number) => get(`/intercept/history/${id}`), interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)), interceptHistoryPage: (page = 1, size = 20, filter: InterceptApprovalFilter = {}) => - get<{ items: InterceptApprovalRow[]; total?: number }>(`/intercept/history?${interceptPageQuery(page, size, filter)}`).then( - (r) => ({ - items: arr(r.items), - total: r.total ?? r.items?.length ?? 0, - }), - ), + get<{ items: InterceptApprovalRow[]; total?: number }>( + `/intercept/history?${interceptPageQuery(page, size, filter)}`, + ).then((r) => ({ + items: arr(r.items), + total: r.total ?? r.items?.length ?? 0, + })), interceptTask: (taskId: string) => get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)), interceptTaskPage: (taskId: string, page = 1, size = 20, filter: InterceptApprovalFilter = {}) => diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 8db6fec..ded2546 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -21,8 +21,6 @@ import type { FindingTraffic, FindingTrafficBinding, IntentAsset, - TrafficEvidenceRole, - TrafficEvidenceSnapshot, ScopeRow, Task, TaskArchive, @@ -32,6 +30,8 @@ import type { TaskLLMResolution, TaskScopeRow, TaskTemplate, + TrafficEvidenceRole, + TrafficEvidenceSnapshot, } from "../types"; import * as D from "./data"; @@ -179,10 +179,16 @@ function advanceMockRetests() { retest.status = "completed"; retest.verdict = "inconclusive"; retest.summary = "演示环境未执行真实验证,无法确认漏洞当前状态。"; - retest.evidence = "### 演示记录\n\n已关联原漏洞。此环境未连接真实 Agent,也未向目标发送请求;请在实际部署中执行复测。"; + retest.evidence = + "### 演示记录\n\n已关联原漏洞。此环境未连接真实 Agent,也未向目标发送请求;请在实际部署中执行复测。"; retest.finished_at = new Date().toISOString(); mockRetestMessages[retest.conversation_id].push({ - seq: 3, worker: "retester", ts: retest.finished_at, kind: "text", summary: retest.summary, detail: retest.evidence, + seq: 3, + worker: "retester", + ts: retest.finished_at, + kind: "text", + summary: retest.summary, + detail: retest.evidence, }); } } @@ -1010,14 +1016,42 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re const kind = q.get("kind") ?? ""; const query = (q.get("q") ?? "").trim().toLowerCase(); const candidates = [ - ...D.findings.map((finding, index) => ({ kind: "finding", id: index + 1, label: finding.name || finding.vulnclass, description: `${finding.severity} · ${finding.summary}` })), + ...D.findings.map((finding, index) => ({ + kind: "finding", + id: index + 1, + label: finding.name || finding.vulnclass, + description: `${finding.severity} · ${finding.summary}`, + })), ...D.companies.map((company) => ({ kind: "company", id: company.id, label: company.name, description: "企业" })), - ...D.assets.map((asset) => ({ kind: asset.type, id: asset.id, label: asset.type === "endpoint" ? `${asset.method || "GET"} ${asset.url}` : asset.app_name || asset.url || asset.domain || asset.ip || asset.bundle_id || `资产 #${asset.id}`, description: [asset.type, asset.page_title, asset.service_name, asset.bundle_id, asset.ip].filter(Boolean).join(" · ") })), + ...D.assets.map((asset) => ({ + kind: asset.type, + id: asset.id, + label: + asset.type === "endpoint" + ? `${asset.method || "GET"} ${asset.url}` + : asset.app_name || asset.url || asset.domain || asset.ip || asset.bundle_id || `资产 #${asset.id}`, + description: [asset.type, asset.page_title, asset.service_name, asset.bundle_id, asset.ip] + .filter(Boolean) + .join(" · "), + })), ]; - const filtered = candidates.filter((item) => (!kind || kind === item.kind || (kind === "asset" && item.kind !== "finding" && item.kind !== "company")) && (!query || String(item.id) === query || `${item.label} ${item.description}`.toLowerCase().includes(query))) - .sort((a, b) => Number(String(b.id) === query) - Number(String(a.id) === query) || b.id - a.id || a.kind.localeCompare(b.kind)); + const filtered = candidates + .filter( + (item) => + (!kind || kind === item.kind || (kind === "asset" && item.kind !== "finding" && item.kind !== "company")) && + (!query || String(item.id) === query || `${item.label} ${item.description}`.toLowerCase().includes(query)), + ) + .sort( + (a, b) => + Number(String(b.id) === query) - Number(String(a.id) === query) || + b.id - a.id || + a.kind.localeCompare(b.kind), + ); const offset = Math.max(0, Number(q.get("cursor")) || 0); - return { items: filtered.slice(offset, offset + 20), next_cursor: offset + 20 < filtered.length ? String(offset + 20) : undefined }; + return { + items: filtered.slice(offset, offset + 20), + next_cursor: offset + 20 < filtered.length ? String(offset + 20) : undefined, + }; } // ── auth:让 demo 直接进主界面 ── @@ -1872,9 +1906,16 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re }; } if (path === "/exploration/findings/retests/active" && m === "GET") { - return { retests: mockRetests - .filter((item) => ["pending", "running"].includes(item.status) && item.conversation_id != null) - .map((item) => ({ id: item.id, finding_id: D.findings[item.finding_id - 1].id, conversation_id: item.conversation_id, status: item.status })) }; + return { + retests: mockRetests + .filter((item) => ["pending", "running"].includes(item.status) && item.conversation_id != null) + .map((item) => ({ + id: item.id, + finding_id: D.findings[item.finding_id - 1].id, + conversation_id: item.conversation_id, + status: item.status, + })), + }; } if (seg[0] === "exploration" && seg[1] === "findings" && seg[3] === "retests") { const finding = mockFindings.find((item) => item.id === seg[2]); @@ -1882,20 +1923,52 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re const findingID = D.findings.findIndex((item) => item.id === finding.id) + 1; if (m === "GET") return { retests: structuredClone(mockRetests.filter((item) => item.finding_id === findingID)) }; if (m === "POST") { - const existing = mockRetests.find((item) => item.finding_id === findingID && ["pending", "running"].includes(item.status)); + const existing = mockRetests.find( + (item) => item.finding_id === findingID && ["pending", "running"].includes(item.status), + ); if (existing) return { retest: structuredClone(existing), created: false }; const now = new Date().toISOString(); const conversationID = mockConversations.reduce((max, item) => Math.max(max, item.id), 0) + 1; - mockConversations.unshift({ id: conversationID, agent_key: "retester", title: `复测 #${finding.id} · ${finding.name || finding.vulnclass}`, pinned: false, created_at: now, updated_at: now }); + mockConversations.unshift({ + id: conversationID, + agent_key: "retester", + title: `复测 #${finding.id} · ${finding.name || finding.vulnclass}`, + pinned: false, + created_at: now, + updated_at: now, + }); const retest: FindingRetest = { - id: mockRetests.length + 1, finding_id: findingID, conversation_id: conversationID, - status: "running", verdict: "", notes: String(b.notes ?? ""), summary: "", evidence: "", - error: "", created_at: now, started_at: now, finished_at: null, + id: mockRetests.length + 1, + finding_id: findingID, + conversation_id: conversationID, + status: "running", + verdict: "", + notes: String(b.notes ?? ""), + summary: "", + evidence: "", + error: "", + created_at: now, + started_at: now, + finished_at: null, }; mockRetests.unshift(retest); mockRetestMessages[conversationID] = [ - { seq: 1, worker: "retester", ts: now, kind: "user", summary: `请复测漏洞 #${finding.id}`, detail: retest.notes }, - { seq: 2, worker: "retester", ts: now, kind: "text", summary: "演示复测进行中(未向目标发送请求)", detail: "演示复测进行中(未向目标发送请求)" }, + { + seq: 1, + worker: "retester", + ts: now, + kind: "user", + summary: `请复测漏洞 #${finding.id}`, + detail: retest.notes, + }, + { + seq: 2, + worker: "retester", + ts: now, + kind: "text", + summary: "演示复测进行中(未向目标发送请求)", + detail: "演示复测进行中(未向目标发送请求)", + }, ]; return { retest: structuredClone(retest), created: true }; } @@ -2070,6 +2143,37 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re }; } if (path === "/exploration/graph") return D.explorationGraph; + // 播报板:和后端 /exploration/nodes 同语义 —— 按创建顺序(mock 里用 ts + id)分页, + // 并带上这一页涉及的边与边另一端的节点。 + if (path === "/exploration/nodes") { + const all = D.explorationGraph.nodes; + const kinds = new Set((q.get("kind") ?? "").split(",").filter(Boolean)); + const states = new Set((q.get("state") ?? "").split(",").filter(Boolean)); + const needle = (q.get("q") ?? "").trim().toLowerCase(); + const asc = q.get("order") === "asc"; + const page = Math.max(1, Number(q.get("page") ?? 1)); + const size = Math.min(200, Math.max(1, Number(q.get("size") ?? 20))); + const rank = (id: string) => all.findIndex((n) => n.id === id); + const matched = all + .filter((n) => (kinds.size === 0 || kinds.has(n.type)) && (states.size === 0 || states.has(n.state))) + .filter((n) => !needle || `${n.payload ?? ""} ${n.origin}`.toLowerCase().includes(needle)) + .sort((a, b) => { + const d = Date.parse(a.ts) - Date.parse(b.ts) || rank(a.id) - rank(b.id); + return asc ? d : -d; + }); + const items = matched.slice((page - 1) * size, page * size); + const onPage = new Set(items.map((n) => n.id)); + const edges = D.explorationGraph.edges.filter((e) => onPage.has(e.src) || onPage.has(e.dst)); + const refs: Record = {}; + for (const e of edges) { + for (const id of [e.src, e.dst]) { + if (onPage.has(id) || refs[id]) continue; + const node = all.find((n) => n.id === id); + if (node) refs[id] = node; + } + } + return { items, total: matched.length, page, size, edges, refs }; + } if (path === "/exploration/activity" && seg.length === 2) { const since = Number(q.get("since") ?? 0); const limit = Math.max(1, Number(q.get("limit") ?? 300)); @@ -2160,10 +2264,16 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re // ── conversations ── if (path === "/conversations" && m === "GET") { sortMockConversations(); - return { conversations: structuredClone(mockConversations.map((conversation) => ({ - ...conversation, - running: mockRetests.some((item) => item.conversation_id === conversation.id && ["pending", "running"].includes(item.status)), - }))) }; + return { + conversations: structuredClone( + mockConversations.map((conversation) => ({ + ...conversation, + running: mockRetests.some( + (item) => item.conversation_id === conversation.id && ["pending", "running"].includes(item.status), + ), + })), + ), + }; } if (path === "/conversations" && m === "POST") { const now = new Date().toISOString(); @@ -2216,7 +2326,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re } if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 3 && m === "GET") { const items = mockRetestMessages[Number(seg[1])] ?? D.conversationMessages[Number(seg[1])] ?? []; - const running = mockRetests.some((item) => item.conversation_id === Number(seg[1]) && ["pending", "running"].includes(item.status)); + const running = mockRetests.some( + (item) => item.conversation_id === Number(seg[1]) && ["pending", "running"].includes(item.status), + ); return { items, cursor: items.length ? items[items.length - 1].seq : 0, running }; } if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 4) { @@ -2265,7 +2377,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (path === "/intercept/rules" && m === "GET") return { rules: D.interceptRules }; if (seg[0] === "intercept" && seg[1] === "rules" && seg[3] === "toggle") return { ok: true, enabled: b.enabled ?? true }; - if (path === "/intercept/pending" && m === "GET") return { pending: mockInterceptHistory.filter((r) => r.status === "pending") }; + if (path === "/intercept/pending" && m === "GET") + return { pending: mockInterceptHistory.filter((r) => r.status === "pending") }; if (seg[0] === "intercept" && seg[1] === "pending" && seg[3] === "decide") { const id = Number(seg[2]); const row = mockInterceptHistory.find((r) => r.id === id) ?? mockInterceptPending.find((r) => r.id === id); @@ -2295,11 +2408,16 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (status && !["pending", "allowed", "denied", "timeout"].includes(status)) throw new Error("无效审批状态"); if (decisionSource && !["model", "rule", "unknown"].includes(decisionSource)) throw new Error("无效判定来源"); const filtered = mockInterceptHistory.filter((row) => { - const source = row.decision_source || (row.rule_id ? "rule" : row.reason?.startsWith("[模型]") ? "model" : "unknown"); - return (seg[1] !== "task" || row.task_id === decodeURIComponent(seg[2])) && - (!status || row.status === status) && (!decisionSource || source === decisionSource); + const source = + row.decision_source || (row.rule_id ? "rule" : row.reason?.startsWith("[模型]") ? "model" : "unknown"); + return ( + (seg[1] !== "task" || row.task_id === decodeURIComponent(seg[2])) && + (!status || row.status === status) && + (!decisionSource || source === decisionSource) + ); }); - if (!q.has("page") && !q.has("size") && !status && !decisionSource) return { items: filtered, total: filtered.length }; + if (!q.has("page") && !q.has("size") && !status && !decisionSource) + return { items: filtered, total: filtered.length }; const page = Math.max(1, Number(q.get("page")) || 1); const size = Math.min(100, Math.max(1, Number(q.get("size")) || 20)); const offset = (page - 1) * size; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 03c319b..71219f8 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -420,6 +420,26 @@ export interface TaskNode { inherited?: boolean; } +// 播报板一页:按创建顺序分页的节点 + 这一页涉及的边 + 边另一端的节点(refs,按 id 索引), +// 这样每条播报都能说清「从哪来、产出了什么」,而不用把整张图拉下来。 +export interface ExplorationNodePage { + items: TaskNode[]; + total: number; + page: number; + size: number; + edges: Edge[]; + refs: Record; +} + +export interface ExplorationNodeQuery { + page?: number; + size?: number; + kinds?: ExploreKind[]; + states?: string[]; + q?: string; + order?: "asc" | "desc"; +} + // 目标管理卡片用的目标(后端已把 payload 拆成 text/vulnclass)。 export interface TaskGoal { id: string;