diff --git a/server/task_asset_mapping.go b/server/task_asset_mapping.go index a8a2b95..1950d8b 100644 --- a/server/task_asset_mapping.go +++ b/server/task_asset_mapping.go @@ -5,7 +5,8 @@ import ( "fmt" "net/http" "strconv" - "strings" + + "github.com/Autumn-27/artex/db" ) // taskAssetMapping starts a visible asset-mapper conversation for the task and @@ -26,7 +27,6 @@ func (s *Server) taskAssetMapping(w http.ResponseWriter, r *http.Request) { return } taskID, _ := strconv.ParseInt(t.ID, 10, 64) - description := strings.TrimSpace(t.Description) message := fmt.Sprintf("请重新梳理任务 #%d 的资产关系。只使用当前任务及其直接关联任务的真实资产数据,按互联网入口、边界服务、内网业务三层说明关系;不要探测、不要修改资产库,也不要把没有证据的连接描述成已验证通路。", taskID) if as := s.m.Assets(); as != nil { if graph, err := as.BuildCoverageGraph(taskID, t.ExpID); err == nil { @@ -59,6 +59,24 @@ func (s *Server) taskAssetMapping(w http.ResponseWriter, r *http.Request) { message += "\n\n页面刚读取的任务资产图真实摘要如下;只依据摘要和任务范围解释,避免把全局资产误当成本任务资产。未列出的节点不要逐一补写:\n" + string(snapshot) } } - s.StartTriggeredRun("asset_mapper", fmt.Sprintf("资产梳理 · 任务 #%d", taskID), message, taskID, false, description, t.Goal) - writeJSON(w, http.StatusAccepted, map[string]any{"status": "accepted", "agent_key": "asset_mapper"}) + title := fmt.Sprintf("资产梳理 · 任务 #%d", taskID) + conversation, err := s.m.pg.CreateConversation("asset_mapper", title, nil) + if err != nil || conversation == nil { + writeErr(w, http.StatusInternalServerError, "创建资产梳理会话失败") + return + } + if _, err := s.m.pg.AppendConvActivity(conversation.ID, db.Activity{ + Worker: "asset_mapper", Kind: "user", Summary: title, Detail: message, + }); err != nil { + writeErr(w, http.StatusInternalServerError, "写入资产梳理会话失败") + return + } + busyKey := s.convBusyKey(conversation.ID) + s.chatMu.Lock() + s.chatBusy[busyKey] = true + s.chatMu.Unlock() + s.runConversation(conversation, message, busyKey, message) + writeJSON(w, http.StatusAccepted, map[string]any{ + "status": "accepted", "agent_key": "asset_mapper", "conversation_id": conversation.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 ec12979..3937987 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 @@ -216,6 +216,8 @@ type G6NodeDatum = { lbl: string; size: number; active: boolean; + x: number; + y: number; }; type G6EdgeDatum = { source: string; target: string; active: boolean }; @@ -245,7 +247,25 @@ function graphLabel(n: CoverageGraphNode): string { } function toG6Nodes(renderNodes: RenderNode[], activeKeys: Set): G6NodeDatum[] { + const buckets: Record = { internet: [], boundary: [], internal: [] }; + for (const rn of renderNodes) { + buckets[rn.fold ? "boundary" : zoneOf(rn.node)].push(rn); + } + const maxCount = Math.max(buckets.internet.length, buckets.boundary.length, buckets.internal.length, 1); + const virtualHeight = Math.max(760, maxCount * 42); + const positions = new Map(); + const xByZone: Record = { internet: 150, boundary: 600, internal: 1050 }; + for (const zone of Object.keys(buckets) as Zone[]) { + const items = buckets[zone]; + items.forEach((rn, index) => { + positions.set(rn.key, { + x: xByZone[zone], + y: 70 + ((index + 1) * (virtualHeight - 140)) / (items.length + 1), + }); + }); + } return renderNodes.map((rn) => { + const position = positions.get(rn.key) ?? { x: 600, y: virtualHeight / 2 }; if (rn.fold) { return { id: rn.key, @@ -256,6 +276,7 @@ function toG6Nodes(renderNodes: RenderNode[], activeKeys: Set): G6NodeDa lbl: `还有 ${rn.hidden.length} 个${kindMeta[rn.kind].label}`, size: 24, active: activeKeys.has(rn.key), + ...position, }; } return { @@ -267,6 +288,7 @@ function toG6Nodes(renderNodes: RenderNode[], activeKeys: Set): G6NodeDa lbl: trunc(graphLabel(rn.node) || kindMeta[rn.kind].label), size: kindMeta[rn.kind].size, active: activeKeys.has(rn.key), + ...position, }; }); } @@ -503,6 +525,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera const [activePath, setActivePath] = React.useState([]); const [loading, setLoading] = React.useState(true); const [mapping, setMapping] = React.useState(false); + const [mappingConversationId, setMappingConversationId] = React.useState(null); const [lastSyncedAt, setLastSyncedAt] = React.useState(null); const containerRef = React.useRef(null); @@ -536,12 +559,37 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera const remapAssets = React.useCallback(async () => { setMapping(true); try { - await api.startAssetMapping(taskId); - } finally { + const run = await api.startAssetMapping(taskId); + setMappingConversationId(run.conversation_id); + } catch { setMapping(false); } }, [taskId]); + React.useEffect(() => { + if (!mappingConversationId) return; + let cancelled = false; + const poll = async () => { + try { + const state = await api.conversationMessages(mappingConversationId, 0); + if (cancelled) return; + if (!state.running) { + setMapping(false); + setMappingConversationId(null); + fetchGraph(); + } + } catch { + // Keep the spinner while the conversation endpoint is temporarily unavailable. + } + }; + void poll(); + const timer = window.setInterval(() => void poll(), 2000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [fetchGraph, mappingConversationId]); + React.useEffect(() => { fetchGraph(); }, [fetchGraph]); @@ -632,13 +680,10 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera }, }, layout: { - // A layered DAG is more legible for network topology than a force - // cloud: parents stay on the left and services/assets flow right. - type: "dagre", - rankdir: "LR", - ranker: "network-simplex", - nodesep: 34, - ranksep: 170, + // Positions are assigned by zone in toG6Nodes. Preset layout keeps + // internet, boundary and internal assets in their corresponding + // columns instead of letting a graph ranker collapse everything. + type: "preset", }, behaviors: ["drag-element", "drag-canvas", "zoom-canvas"], }); @@ -733,7 +778,13 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
资产梳理 Agent
- {loading ? "正在更新资产关系…" : lastSyncedAt ? `最近更新 ${lastSyncedAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}` : "等待首次梳理"} + {mapping + ? "正在梳理资产关系…" + : loading + ? "正在更新资产关系…" + : lastSyncedAt + ? `最近更新 ${lastSyncedAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}` + : "等待首次梳理"}
@@ -753,7 +804,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera )}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2fc4aeb..7b70e24 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -451,7 +451,8 @@ export const api = { }>(`/tasks/${id}/coverage`), // 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。 taskCoverageGraph: (id: string) => get(`/tasks/${id}/coverage-graph`), - startAssetMapping: (id: string) => post<{ status: string; agent_key: string }>(`/tasks/${id}/asset-mapping`, {}), + startAssetMapping: (id: string) => + post<{ status: string; agent_key: string; conversation_id: number }>(`/tasks/${id}/asset-mapping`, {}), // 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。 usageStats: (days = 365) => get(`/tokens/usage?days=${days}`), // ---- 目标管理(总览)----