From 44b4a4beba36c98ffa382fb71b0a01f4b92614ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Fri, 12 Jun 2026 20:14:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B3=A8=E5=86=8C=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E5=85=A5=E5=BA=93=E6=8E=A5=E5=8F=A3=20+=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=A4=A7=E8=A7=84=E6=A8=A1=E7=9F=A5=E8=AF=86=E5=9B=BE=E8=B0=B1?= =?UTF-8?q?=E5=8A=9B=E5=AF=BC=E5=90=91=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 Gateway 挂载 /api/knowledge-ingest 路由(发布 md 制品) - 知识图谱力模拟按节点规模缩放斥力/截断距离/冷却速率,避免数百节点时布局长时间不收敛 - 斥力与碰撞改为基于均匀网格的近邻对计算,并增加每帧最大位移限制防止抖动与飞出 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../knowledge-graph/GraphCanvas.tsx | 100 +++++---- .../components/knowledge-graph/graphUtils.ts | 192 +++++++++++++----- .../backend/app/gateway/app.py | 4 + 3 files changed, 206 insertions(+), 90 deletions(-) diff --git a/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx b/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx index 8b3b3bc7..84987919 100644 --- a/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx +++ b/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx @@ -245,9 +245,10 @@ export const GraphCanvas = forwardRef( // first paint is already roughly laid out instead of an exploding // cloud that drifts out of the viewport. if (props.layoutType === "force") { - const deadline = performance.now() + 120; + const budget = simNodes.length > 350 ? 280 : 120; + const deadline = performance.now() + budget; let guard = 0; - while (sim.alpha > 0.25 && guard < 300 && performance.now() < deadline) { + while (sim.alpha > 0.25 && guard < 600 && performance.now() < deadline) { sim.tick(); guard += 1; } @@ -295,9 +296,15 @@ export const GraphCanvas = forwardRef( const clampZoom = (k: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, k)); + // Notify React of zoom changes only when k actually moved — a state + // update per animation frame re-renders the whole page while settling. + const lastNotifiedZoomRef = useRef(0); const setTransform = useCallback((t: Transform) => { transformRef.current = t; - propsRef.current.onZoomChange(t.k); + if (Math.abs(t.k - lastNotifiedZoomRef.current) > 0.004) { + lastNotifiedZoomRef.current = t.k; + propsRef.current.onZoomChange(t.k); + } needsDrawRef.current = true; }, []); @@ -326,32 +333,39 @@ export const GraphCanvas = forwardRef( [setTransform], ); + const computeFitTransform = useCallback((): Transform | null => { + const sim = simRef.current; + const { w, h } = sizeRef.current; + if (!sim || !sim.nodes.length || !w || !h) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const n of sim.nodes) { + minX = Math.min(minX, n.x - n.radius); + minY = Math.min(minY, n.y - n.radius); + maxX = Math.max(maxX, n.x + n.radius); + maxY = Math.max(maxY, n.y + n.radius); + } + // A single bad coordinate must not poison the viewport transform. + if (!Number.isFinite(minX + minY + maxX + maxY)) return null; + const pad = 70; + const bw = Math.max(maxX - minX, 1); + const bh = Math.max(maxY - minY, 1); + const k = clampZoom(Math.min((w - pad * 2) / bw, (h - pad * 2) / bh, 2.2)); + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + return { x: w / 2 - cx * k, y: h / 2 - cy * k, k }; + }, []); + const fitView = useCallback( (animated = true) => { - const sim = simRef.current; - const { w, h } = sizeRef.current; - if (!sim || !sim.nodes.length || !w || !h) return; - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (const n of sim.nodes) { - minX = Math.min(minX, n.x - n.radius); - minY = Math.min(minY, n.y - n.radius); - maxX = Math.max(maxX, n.x + n.radius); - maxY = Math.max(maxY, n.y + n.radius); - } - const pad = 70; - const bw = Math.max(maxX - minX, 1); - const bh = Math.max(maxY - minY, 1); - const k = clampZoom(Math.min((w - pad * 2) / bw, (h - pad * 2) / bh, 2.2)); - const cx = (minX + maxX) / 2; - const cy = (minY + maxY) / 2; - const target = { x: w / 2 - cx * k, y: h / 2 - cy * k, k }; + const target = computeFitTransform(); + if (!target) return; if (animated) animateTo(target); else setTransform(target); }, - [animateTo, setTransform], + [animateTo, computeFitTransform, setTransform], ); const focusNode = useCallback( @@ -527,11 +541,13 @@ export const GraphCanvas = forwardRef( const r = baseRadius * (n.id === hover?.id ? 1.15 : 1); ctx.globalAlpha = alpha; - // Glow for hubs and the active node. - if ( - alpha > 0.5 && - (isActive || isSelected || isExploreRoot || isExpanded || n.degree >= labelThreshold) - ) { + // Glow for hubs and the active node. Canvas shadows are expensive, + // so on large graphs only the focused nodes keep theirs. + const glowEligible = + sim.nodes.length <= 450 + ? isActive || isSelected || isExploreRoot || isExpanded || n.degree >= labelThreshold + : isActive || isSelected || isExploreRoot; + if (alpha > 0.5 && glowEligible) { ctx.shadowColor = fillColor; ctx.shadowBlur = isActive || isSelected || isExploreRoot ? 24 : 12; } else { @@ -692,6 +708,7 @@ export const GraphCanvas = forwardRef( }; if (progress >= 1) { animRef.current = null; + lastNotifiedZoomRef.current = transformRef.current.k; propsRef.current.onZoomChange(transformRef.current.k); } active = true; @@ -699,10 +716,19 @@ export const GraphCanvas = forwardRef( if (sim && layoutRef.current === "force" && sim.tick()) { active = true; - // Until the user pans/zooms/drags, keep the settling layout fully in - // view every frame so nodes never drift out of the viewport. + // Until the user pans/zooms/drags, gently track the settling layout + // so it stays in view without the viewport snapping every frame. if (!interactedRef.current && sizeRef.current.w) { - fitView(false); + const target = computeFitTransform(); + if (target) { + const t = transformRef.current; + const ease = t.k === 0 ? 1 : 0.16; + setTransform({ + x: t.x + (target.x - t.x) * ease, + y: t.y + (target.y - t.y) * ease, + k: t.k + (target.k - t.k) * ease, + }); + } autoFitRef.current = true; } else if (!autoFitRef.current && sim.alpha < 0.3) { autoFitRef.current = true; @@ -721,7 +747,7 @@ export const GraphCanvas = forwardRef( }; rafRef.current = requestAnimationFrame(loop); return () => cancelAnimationFrame(rafRef.current); - }, [draw, fitView]); + }, [computeFitTransform, draw, fitView, setTransform]); // --------------------------------------------------------------- resize @@ -801,7 +827,6 @@ export const GraphCanvas = forwardRef( const handlePointerDown = (e: React.PointerEvent) => { if (e.button !== 0) return; - interactedRef.current = true; const { sx, sy } = localPoint(e); const { x: wx, y: wy } = screenToWorld(sx, sy); const node = hitTest(wx, wy); @@ -828,7 +853,12 @@ export const GraphCanvas = forwardRef( if (drag && drag.pointerId === e.pointerId) { const dx = sx - drag.startX; const dy = sy - drag.startY; - if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true; + if (Math.abs(dx) + Math.abs(dy) > 3) { + drag.moved = true; + // Only a real pan/drag takes over the camera; a plain click keeps + // the auto-follow so the settling graph can't drift out of view. + interactedRef.current = true; + } if (drag.node) { const world = screenToWorld(sx, sy); drag.node.fx = world.x; diff --git a/frontend-web/src/components/knowledge-graph/graphUtils.ts b/frontend-web/src/components/knowledge-graph/graphUtils.ts index b5331630..35e9cab4 100644 --- a/frontend-web/src/components/knowledge-graph/graphUtils.ts +++ b/frontend-web/src/components/knowledge-graph/graphUtils.ts @@ -239,15 +239,41 @@ export function applyCircularLayout(nodes: SimNode[]): void { export class ForceSimulation { alpha = 1; readonly alphaMin = 0.004; - private readonly alphaDecay = 0.0228; + private readonly alphaDecay: number; private readonly velocityDecay = 0.38; - private readonly chargeStrength = -420; - private readonly chargeCutoff2 = 600 * 600; + private readonly chargeStrength: number; + private readonly chargeCutoff: number; + private readonly chargeCutoff2: number; + /** Hard cap on per-tick movement so dense graphs can't oscillate or fly off. */ + private readonly maxSpeed = 30; constructor( public nodes: SimNode[], public edges: SimEdge[], - ) {} + ) { + // Scale forces down and cool faster as the graph grows — with the + // small-graph constants a few hundred nodes keep shoving each other + // around for minutes and the layout never settles. + const n = nodes.length; + if (n > 600) { + this.chargeStrength = -160; + this.chargeCutoff = 260; + this.alphaDecay = 0.05; + } else if (n > 300) { + this.chargeStrength = -240; + this.chargeCutoff = 340; + this.alphaDecay = 0.038; + } else if (n > 150) { + this.chargeStrength = -320; + this.chargeCutoff = 440; + this.alphaDecay = 0.03; + } else { + this.chargeStrength = -420; + this.chargeCutoff = 600; + this.alphaDecay = 0.0228; + } + this.chargeCutoff2 = this.chargeCutoff * this.chargeCutoff; + } reheat(alpha = 0.9): void { this.alpha = Math.max(this.alpha, alpha); @@ -276,31 +302,8 @@ export class ForceSimulation { s.vy += dy * 0.5; } - // Many-body repulsion with a distance cutoff. - for (let i = 0; i < nodes.length; i++) { - const ni = nodes[i]; - for (let j = i + 1; j < nodes.length; j++) { - const nj = nodes[j]; - let dx = nj.x - ni.x; - let dy = nj.y - ni.y; - let d2 = dx * dx + dy * dy; - if (d2 > this.chargeCutoff2) continue; - if (d2 < 1) { - // Deterministic nudge for coincident nodes. - dx = ((i % 3) - 1) * 0.5 || 0.5; - dy = ((j % 3) - 1) * 0.5 || -0.5; - d2 = dx * dx + dy * dy; - } - const d = Math.sqrt(d2); - const f = (this.chargeStrength * a) / d2; - const fx = (dx / d) * f; - const fy = (dy / d) * f; - ni.vx += fx; - ni.vy += fy; - nj.vx -= fx; - nj.vy -= fy; - } - } + // Repulsion + collision over nearby pairs only (uniform grid). + this.applyPairForces(a); // Gentle pull toward the origin keeps disconnected clusters on screen. for (const n of nodes) { @@ -308,33 +311,9 @@ export class ForceSimulation { n.vy += (0 - n.y) * 0.05 * a; } - // Positional collision resolution (prevents node overlap). - for (let i = 0; i < nodes.length; i++) { - const ni = nodes[i]; - for (let j = i + 1; j < nodes.length; j++) { - const nj = nodes[j]; - const minDist = ni.radius + nj.radius + 8; - let dx = nj.x - ni.x; - let dy = nj.y - ni.y; - const d2 = dx * dx + dy * dy; - if (d2 >= minDist * minDist || d2 === 0) continue; - const d = Math.sqrt(d2); - const push = ((minDist - d) / d) * 0.5; - dx *= push; - dy *= push; - if (ni.fx == null) { - ni.x -= dx; - ni.y -= dy; - } - if (nj.fx == null) { - nj.x += dx; - nj.y += dy; - } - } - } - // Integrate velocities. - for (const n of nodes) { + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; if (n.fx != null && n.fy != null) { n.x = n.fx; n.y = n.fy; @@ -343,10 +322,113 @@ export class ForceSimulation { } else { n.vx *= 1 - this.velocityDecay; n.vy *= 1 - this.velocityDecay; + const speed = Math.sqrt(n.vx * n.vx + n.vy * n.vy); + if (speed > this.maxSpeed) { + const s = this.maxSpeed / speed; + n.vx *= s; + n.vy *= s; + } n.x += n.vx; n.y += n.vy; } + if (!Number.isFinite(n.x) || !Number.isFinite(n.y)) { + // A NaN/Infinity coordinate would blank the whole canvas (the + // bounding box and fit transform become NaN); park the node instead. + const r = 40 + 18 * Math.sqrt(i + 1); + n.x = r * Math.cos(i * GOLDEN_ANGLE); + n.y = r * Math.sin(i * GOLDEN_ANGLE); + n.vx = 0; + n.vy = 0; + } } return true; } + + /** + * Many-body repulsion + collision resolution, restricted to pairs within + * one grid cell (cell size = charge cutoff). Two nodes farther apart than + * the cutoff never interact, so binning by cell keeps the scan close to + * O(n) for spread-out layouts instead of O(n²) over all pairs. + */ + private applyPairForces(a: number): void { + const cellSize = this.chargeCutoff; + const grid = new Map(); + for (const n of this.nodes) { + const cx = Math.floor(n.x / cellSize); + const cy = Math.floor(n.y / cellSize); + const key = `${cx},${cy}`; + let bucket = grid.get(key); + if (!bucket) grid.set(key, (bucket = { cx, cy, items: [] })); + bucket.items.push(n); + } + // Half of the 8-neighborhood so every cell pair is visited exactly once. + const offsets = [ + [0, 0], + [1, 0], + [0, 1], + [1, 1], + [1, -1], + ] as const; + for (const bucket of grid.values()) { + for (const [ox, oy] of offsets) { + const other = + ox === 0 && oy === 0 + ? bucket + : grid.get(`${bucket.cx + ox},${bucket.cy + oy}`); + if (!other) continue; + const left = bucket.items; + const right = other.items; + if (other === bucket) { + for (let i = 0; i < left.length; i++) { + for (let j = i + 1; j < left.length; j++) { + this.pairForce(left[i], left[j], i, j, a); + } + } + } else { + for (let i = 0; i < left.length; i++) { + for (let j = 0; j < right.length; j++) { + this.pairForce(left[i], right[j], i, j, a); + } + } + } + } + } + } + + private pairForce(ni: SimNode, nj: SimNode, i: number, j: number, a: number): void { + let dx = nj.x - ni.x; + let dy = nj.y - ni.y; + let d2 = dx * dx + dy * dy; + if (d2 > this.chargeCutoff2) return; + if (d2 < 1) { + // Deterministic nudge for coincident nodes. + dx = ((i % 3) - 1) * 0.5 || 0.5; + dy = ((j % 3) - 1) * 0.5 || -0.5; + d2 = dx * dx + dy * dy; + } + const d = Math.sqrt(d2); + const f = (this.chargeStrength * a) / d2; + const fx = (dx / d) * f; + const fy = (dy / d) * f; + ni.vx += fx; + ni.vy += fy; + nj.vx -= fx; + nj.vy -= fy; + + // Positional collision resolution (prevents node overlap). + const minDist = ni.radius + nj.radius + 8; + if (d < minDist) { + const push = ((minDist - d) / d) * 0.5; + const px = dx * push; + const py = dy * push; + if (ni.fx == null) { + ni.x -= px; + ni.y -= py; + } + if (nj.fx == null) { + nj.x += px; + nj.y += py; + } + } + } } diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 0752db2d..fab956cf 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -40,6 +40,7 @@ from app.gateway.routers import ( html_page_favorites, intent, knowledge, + knowledge_ingest, light_apps, llm_metrics, mcp, @@ -1359,6 +1360,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # Product knowledge base API is mounted at /api/knowledge app.include_router(knowledge.router) + # External knowledge ingest (publish md artifacts) at /api/knowledge-ingest + app.include_router(knowledge_ingest.router) + # System settings API is mounted at /api/system-settings app.include_router(system_settings.router)