From e684de30bdbf0a8a5e006b0a4474b773bea2da77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Thu, 11 Jun 2026 14:52:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(knowledge):=20=E7=9F=A5=E8=AF=86=E5=BA=93?= =?UTF-8?q?=20wiki/=E7=9F=A5=E8=AF=86=E5=9B=BE=E8=B0=B1=E9=87=8D=E6=9E=84?= =?UTF-8?q?=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=8F=91=E5=B8=83=E5=B9=BF=E5=9C=BA?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 知识库页拆分出 wiki 组件(侧边栏/目录树/Markdown 阅读器等) - 知识图谱页拆分出 knowledge-graph 组件并增强交互 - 新增发布广场选择弹窗 publish-square-picker-dialog - config.yaml 新增 MiniMax-M2.5 / Ling-flash-2.0 / DeepSeek-V4-Pro 模型 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../knowledge-graph/GraphCanvas.tsx | 785 ++++++++++++++++++ .../knowledge-graph/GraphDetailPanel.tsx | 210 +++++ .../knowledge-graph/GraphFooter.tsx | 57 ++ .../knowledge-graph/GraphHeader.tsx | 147 ++++ .../knowledge-graph/GraphSidebar.tsx | 163 ++++ .../knowledge-graph/GraphTooltip.tsx | 29 + .../knowledge-graph/graphStyles.css | 779 +++++++++++++++++ .../components/knowledge-graph/graphTypes.ts | 99 +++ .../components/knowledge-graph/graphUtils.ts | 348 ++++++++ .../src/components/wiki/Breadcrumb.tsx | 25 + .../src/components/wiki/DocumentToolbar.tsx | 111 +++ .../components/wiki/EmptyDocumentState.tsx | 18 + .../src/components/wiki/KnowledgeSidebar.tsx | 144 ++++ .../src/components/wiki/KnowledgeTree.tsx | 131 +++ .../src/components/wiki/MarkdownViewer.tsx | 49 ++ frontend-web/src/components/wiki/TocPanel.tsx | 65 ++ .../src/components/wiki/WikiContent.tsx | 335 ++++++++ .../src/components/wiki/WikiHeader.tsx | 112 +++ frontend-web/src/components/wiki/wiki.css | 145 ++++ frontend-web/src/components/wiki/wikiTypes.ts | 17 + frontend-web/src/components/wiki/wikiUtils.ts | 169 ++++ .../workspace/agents/agent-card.tsx | 33 + .../workspace/agents/agent-gallery.tsx | 139 ++-- .../publish-square-picker-dialog.tsx | 114 +++ .../settings/skill-settings-page.tsx | 116 ++- frontend-web/src/core/knowledge/hooks.ts | 9 + frontend-web/src/pages/KnowledgeBasePage.tsx | 645 ++++---------- frontend-web/src/pages/KnowledgeGraphPage.tsx | 463 +++++++++-- offline-backend-20260512/backend/config.yaml | 30 + offline-backend-20260512/config.yaml | 22 +- 30 files changed, 4827 insertions(+), 682 deletions(-) create mode 100644 frontend-web/src/components/knowledge-graph/GraphCanvas.tsx create mode 100644 frontend-web/src/components/knowledge-graph/GraphDetailPanel.tsx create mode 100644 frontend-web/src/components/knowledge-graph/GraphFooter.tsx create mode 100644 frontend-web/src/components/knowledge-graph/GraphHeader.tsx create mode 100644 frontend-web/src/components/knowledge-graph/GraphSidebar.tsx create mode 100644 frontend-web/src/components/knowledge-graph/GraphTooltip.tsx create mode 100644 frontend-web/src/components/knowledge-graph/graphStyles.css create mode 100644 frontend-web/src/components/knowledge-graph/graphTypes.ts create mode 100644 frontend-web/src/components/knowledge-graph/graphUtils.ts create mode 100644 frontend-web/src/components/wiki/Breadcrumb.tsx create mode 100644 frontend-web/src/components/wiki/DocumentToolbar.tsx create mode 100644 frontend-web/src/components/wiki/EmptyDocumentState.tsx create mode 100644 frontend-web/src/components/wiki/KnowledgeSidebar.tsx create mode 100644 frontend-web/src/components/wiki/KnowledgeTree.tsx create mode 100644 frontend-web/src/components/wiki/MarkdownViewer.tsx create mode 100644 frontend-web/src/components/wiki/TocPanel.tsx create mode 100644 frontend-web/src/components/wiki/WikiContent.tsx create mode 100644 frontend-web/src/components/wiki/WikiHeader.tsx create mode 100644 frontend-web/src/components/wiki/wiki.css create mode 100644 frontend-web/src/components/wiki/wikiTypes.ts create mode 100644 frontend-web/src/components/wiki/wikiUtils.ts create mode 100644 frontend-web/src/components/workspace/publish-square-picker-dialog.tsx diff --git a/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx b/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx new file mode 100644 index 00000000..5be8491b --- /dev/null +++ b/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx @@ -0,0 +1,785 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + type ReactNode, +} from "react"; + +import type { + GraphEdge, + GraphNode, + LayoutType, + SimEdge, + SimNode, + TooltipState, + Transform, +} from "./graphTypes"; +import { + applyCircularLayout, + ForceSimulation, + getEdgeInfo, + getEdgeLabel, + getLinkDistance, + getNodeKind, + getNodeRadius, + getNodeTitle, + NODE_KIND_META, + seedPositions, + truncateLabel, +} from "./graphUtils"; + +const MIN_ZOOM = 0.15; +const MAX_ZOOM = 4; + +export interface GraphCanvasHandle { + zoomIn: () => void; + zoomOut: () => void; + fitView: () => void; + relayout: () => void; + focusNode: (id: string) => void; + exportImage: () => void; +} + +interface GraphCanvasProps { + nodes: GraphNode[]; + edges: GraphEdge[]; + /** Degrees computed on the FULL graph so node sizes stay stable under filters. */ + degrees: Map; + selectedNodeId: string | null; + /** null = no active search; otherwise the set of matching node ids. */ + searchMatchIds: Set | null; + layoutType: LayoutType; + showLabels: boolean; + onSelectNode: (id: string | null) => void; + onTooltip: (tooltip: TooltipState | null) => void; + onZoomChange: (k: number) => void; + /** Floating pills rendered top-left over the canvas. */ + toolbar?: ReactNode; + /** Centered overlay (empty / loading states). */ + overlay?: ReactNode; +} + +interface DragState { + pointerId: number; + node: SimNode | null; + startX: number; + startY: number; + originX: number; + originY: number; + moved: boolean; +} + +const easeCubicOut = (t: number) => 1 - Math.pow(1 - t, 3); + +export const GraphCanvas = forwardRef( + function GraphCanvas(props, ref) { + const containerRef = useRef(null); + const canvasRef = useRef(null); + const minimapRef = useRef(null); + + const simRef = useRef(null); + const nodeMapRef = useRef>(new Map()); + const adjacencyRef = useRef>>(new Map()); + const labelThresholdRef = useRef(3); + + const transformRef = useRef({ x: 0, y: 0, k: 0 }); + const sizeRef = useRef({ w: 0, h: 0 }); + const hoverRef = useRef(null); + const dragRef = useRef(null); + const animRef = useRef<{ + from: Transform; + to: Transform; + start: number; + duration: number; + } | null>(null); + const needsDrawRef = useRef(true); + const autoFitRef = useRef(false); + const rafRef = useRef(0); + + // Latest props readable from the rAF loop / event handlers. + const propsRef = useRef(props); + propsRef.current = props; + + const requestDraw = useCallback(() => { + needsDrawRef.current = true; + }, []); + + // ---------------------------------------------------------------- build + + useEffect(() => { + const previous = nodeMapRef.current; + const simNodes: SimNode[] = props.nodes.map((n) => { + const degree = props.degrees.get(n.id) ?? 0; + const kind = getNodeKind(n); + const old = previous.get(n.id); + return { + id: n.id, + node: n, + kind, + degree, + radius: getNodeRadius(degree), + color: NODE_KIND_META[kind].color, + x: old?.x ?? Number.NaN, + y: old?.y ?? Number.NaN, + vx: 0, + vy: 0, + fx: old?.fx ?? null, + fy: old?.fy ?? null, + }; + }); + + const fresh = simNodes.filter((n) => Number.isNaN(n.x)); + if (fresh.length === simNodes.length) { + seedPositions(simNodes); + } else { + // Filters re-enabled some nodes: spiral the newcomers in near the center. + fresh.forEach((n, i) => { + const r = 60 + 16 * Math.sqrt(i + 1); + const angle = i * 2.3999632297; + n.x = r * Math.cos(angle); + n.y = r * Math.sin(angle); + }); + } + + const byId = new Map(simNodes.map((n) => [n.id, n])); + const simEdges: SimEdge[] = []; + const adjacency = new Map>(); + for (const e of props.edges) { + const source = byId.get(e.source); + const target = byId.get(e.target); + if (!source || !target) continue; + const label = getEdgeLabel(e); + simEdges.push({ + id: e.id ?? `${e.source}->${e.target}:${label}`, + source, + target, + label, + info: getEdgeInfo(label), + weight: e.weight ?? 1, + distance: getLinkDistance(label) + source.radius + target.radius, + }); + if (!adjacency.has(source.id)) adjacency.set(source.id, new Set()); + if (!adjacency.has(target.id)) adjacency.set(target.id, new Set()); + adjacency.get(source.id)!.add(target.id); + adjacency.get(target.id)!.add(source.id); + } + + // Label budget: always label hubs; ~20 labels max by default. + const sortedDegrees = simNodes.map((n) => n.degree).sort((a, b) => b - a); + labelThresholdRef.current = Math.max( + 2, + sortedDegrees[Math.min(19, sortedDegrees.length - 1)] ?? 2, + ); + + nodeMapRef.current = byId; + adjacencyRef.current = adjacency; + const sim = new ForceSimulation(simNodes, simEdges); + if (props.layoutType === "circular") { + applyCircularLayout(simNodes); + sim.alpha = 0; + } else { + sim.alpha = previous.size === 0 ? 1 : 0.5; + } + simRef.current = sim; + if (previous.size === 0) autoFitRef.current = false; + requestDraw(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.nodes, props.edges, props.degrees]); + + // Layout switching. + const layoutRef = useRef(props.layoutType); + useEffect(() => { + if (layoutRef.current === props.layoutType) return; + layoutRef.current = props.layoutType; + const sim = simRef.current; + if (!sim) return; + if (props.layoutType === "circular") { + applyCircularLayout(sim.nodes); + sim.alpha = 0; + } else { + for (const n of sim.nodes) { + n.fx = null; + n.fy = null; + } + sim.reheat(0.9); + } + autoFitRef.current = false; + requestDraw(); + }, [props.layoutType, requestDraw]); + + // Redraw on highlight-affecting prop changes. + useEffect(() => { + requestDraw(); + }, [props.selectedNodeId, props.searchMatchIds, props.showLabels, requestDraw]); + + // ------------------------------------------------------------ transform + + const clampZoom = (k: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, k)); + + const setTransform = useCallback((t: Transform) => { + transformRef.current = t; + propsRef.current.onZoomChange(t.k); + needsDrawRef.current = true; + }, []); + + const animateTo = useCallback((to: Transform, duration = 350) => { + animRef.current = { + from: { ...transformRef.current }, + to, + start: performance.now(), + duration, + }; + needsDrawRef.current = true; + }, []); + + const zoomBy = useCallback( + (factor: number, cx?: number, cy?: number) => { + const t = transformRef.current; + const { w, h } = sizeRef.current; + const px = cx ?? w / 2; + const py = cy ?? h / 2; + const k = clampZoom(t.k * factor); + const wx = (px - t.x) / t.k; + const wy = (py - t.y) / t.k; + setTransform({ x: px - wx * k, y: py - wy * k, k }); + }, + [setTransform], + ); + + 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 }; + if (animated) animateTo(target); + else setTransform(target); + }, + [animateTo, setTransform], + ); + + const focusNode = useCallback( + (id: string) => { + const node = nodeMapRef.current.get(id); + const { w, h } = sizeRef.current; + if (!node || !w || !h) return; + const k = clampZoom(Math.max(transformRef.current.k, 1.4)); + animateTo({ x: w / 2 - node.x * k, y: h / 2 - node.y * k, k }); + }, + [animateTo], + ); + + const relayout = useCallback(() => { + const sim = simRef.current; + if (!sim) return; + if (layoutRef.current === "circular") { + applyCircularLayout(sim.nodes); + sim.alpha = 0; + fitView(true); + } else { + seedPositions(sim.nodes); + sim.alpha = 1; + autoFitRef.current = false; + } + requestDraw(); + }, [fitView, requestDraw]); + + const exportImage = useCallback(() => { + const main = canvasRef.current; + if (!main) return; + const out = document.createElement("canvas"); + out.width = main.width; + out.height = main.height; + const ctx = out.getContext("2d"); + if (!ctx) return; + ctx.fillStyle = "#0b1020"; + ctx.fillRect(0, 0, out.width, out.height); + ctx.drawImage(main, 0, 0); + out.toBlob((blob) => { + if (!blob) return; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "knowledge-graph.png"; + a.click(); + URL.revokeObjectURL(url); + }, "image/png"); + }, []); + + useImperativeHandle( + ref, + () => ({ + zoomIn: () => zoomBy(1.25), + zoomOut: () => zoomBy(1 / 1.25), + fitView: () => fitView(true), + relayout, + focusNode, + exportImage, + }), + [zoomBy, fitView, relayout, focusNode, exportImage], + ); + + // ----------------------------------------------------------------- draw + + const draw = useCallback(() => { + const canvas = canvasRef.current; + const sim = simRef.current; + if (!canvas || !sim) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const dpr = window.devicePixelRatio || 1; + const { w, h } = sizeRef.current; + const t = transformRef.current; + const p = propsRef.current; + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + ctx.translate(t.x, t.y); + ctx.scale(t.k, t.k); + + const hover = hoverRef.current; + const activeId = hover?.id ?? p.selectedNodeId; + const neighbors = activeId ? adjacencyRef.current.get(activeId) : undefined; + const search = p.searchMatchIds; + const k = t.k || 1; + + // ---- edges + for (const e of sim.edges) { + const touchesActive = + activeId !== null && + (e.source.id === activeId || e.target.id === activeId); + let alpha: number; + if (activeId) alpha = touchesActive ? 0.95 : 0.07; + else if (search) + alpha = search.has(e.source.id) || search.has(e.target.id) ? 0.75 : 0.06; + else alpha = 0.38; + + ctx.globalAlpha = alpha; + ctx.strokeStyle = e.info.color; + ctx.lineWidth = (touchesActive ? 2.4 : 1.2) / k; + ctx.setLineDash(e.info.dashed ? [6 / k, 5 / k] : []); + ctx.beginPath(); + ctx.moveTo(e.source.x, e.source.y); + ctx.lineTo(e.target.x, e.target.y); + ctx.stroke(); + + // Relation label at the midpoint while a node is focused. + if (touchesActive && k >= 0.55) { + const mx = (e.source.x + e.target.x) / 2; + const my = (e.source.y + e.target.y) / 2; + ctx.setLineDash([]); + ctx.font = `${10.5 / k}px "Noto Sans SC", system-ui, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.lineWidth = 3 / k; + ctx.strokeStyle = "rgba(2, 6, 23, 0.9)"; + ctx.strokeText(e.info.zh, mx, my); + ctx.fillStyle = e.info.color; + ctx.fillText(e.info.zh, mx, my); + } + } + ctx.setLineDash([]); + + // ---- nodes + const labelThreshold = labelThresholdRef.current; + for (const n of sim.nodes) { + const isActive = n.id === activeId; + const isSelected = n.id === p.selectedNodeId; + const isNeighbor = neighbors?.has(n.id) ?? false; + let alpha: number; + if (activeId) alpha = isActive || isNeighbor ? 1 : 0.14; + else if (search) alpha = search.has(n.id) ? 1 : 0.14; + else alpha = 1; + + const r = n.radius * (n.id === hover?.id ? 1.15 : 1); + ctx.globalAlpha = alpha; + + // Glow for hubs and the active node. + if (alpha > 0.5 && (isActive || isSelected || n.degree >= labelThreshold)) { + ctx.shadowColor = n.color; + ctx.shadowBlur = isActive || isSelected ? 22 : 12; + } else { + ctx.shadowBlur = 0; + } + + ctx.beginPath(); + ctx.arc(n.x, n.y, r, 0, Math.PI * 2); + ctx.fillStyle = n.color; + ctx.fill(); + ctx.shadowBlur = 0; + + // Rim. + ctx.lineWidth = 1.4 / k; + ctx.strokeStyle = "rgba(2, 6, 23, 0.55)"; + ctx.stroke(); + + // Selection: double ring. + if (isSelected) { + ctx.beginPath(); + ctx.arc(n.x, n.y, r + 4 / k, 0, Math.PI * 2); + ctx.lineWidth = 2 / k; + ctx.strokeStyle = "#ffffff"; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(n.x, n.y, r + 8 / k, 0, Math.PI * 2); + ctx.lineWidth = 1.2 / k; + ctx.strokeStyle = n.color; + ctx.stroke(); + } else if (n.id === hover?.id) { + ctx.beginPath(); + ctx.arc(n.x, n.y, r + 3.5 / k, 0, Math.PI * 2); + ctx.lineWidth = 1.6 / k; + ctx.strokeStyle = "rgba(255, 255, 255, 0.85)"; + ctx.stroke(); + } + + // Pin marker for dragged-and-fixed nodes. + if (n.fx != null && !isSelected) { + ctx.beginPath(); + ctx.arc(n.x, n.y, Math.max(r * 0.28, 2 / k), 0, Math.PI * 2); + ctx.fillStyle = "rgba(255, 255, 255, 0.8)"; + ctx.fill(); + } + + // Label. + const wantsLabel = + isActive || + isSelected || + isNeighbor || + (search?.has(n.id) ?? false) || + (p.showLabels && n.degree >= labelThreshold && k >= 0.45); + if (wantsLabel && alpha > 0.5) { + const text = truncateLabel(getNodeTitle(n.node), isActive ? 26 : 14); + const fontSize = (isActive || isSelected ? 13 : 12) / k; + ctx.font = `600 ${fontSize}px "Noto Sans SC", system-ui, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.lineWidth = 3.5 / k; + ctx.strokeStyle = "rgba(2, 6, 23, 0.9)"; + ctx.strokeText(text, n.x, n.y + r + 5 / k); + ctx.fillStyle = isActive || isSelected ? "#ffffff" : "#e2e8f0"; + ctx.fillText(text, n.x, n.y + r + 5 / k); + } + } + ctx.globalAlpha = 1; + + drawMinimap(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const drawMinimap = useCallback(() => { + const minimap = minimapRef.current; + const sim = simRef.current; + if (!minimap || !sim || !sim.nodes.length) return; + const ctx = minimap.getContext("2d"); + if (!ctx) return; + const mw = minimap.width; + const mh = minimap.height; + ctx.clearRect(0, 0, mw, mh); + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const n of sim.nodes) { + minX = Math.min(minX, n.x); + minY = Math.min(minY, n.y); + maxX = Math.max(maxX, n.x); + maxY = Math.max(maxY, n.y); + } + const pad = 10; + const scale = Math.min( + (mw - pad * 2) / Math.max(maxX - minX, 1), + (mh - pad * 2) / Math.max(maxY - minY, 1), + ); + const ox = (mw - (maxX - minX) * scale) / 2 - minX * scale; + const oy = (mh - (maxY - minY) * scale) / 2 - minY * scale; + + for (const n of sim.nodes) { + ctx.beginPath(); + ctx.arc(n.x * scale + ox, n.y * scale + oy, 1.6, 0, Math.PI * 2); + ctx.fillStyle = n.color; + ctx.globalAlpha = 0.9; + ctx.fill(); + } + ctx.globalAlpha = 1; + + // Viewport rectangle. + const t = transformRef.current; + const { w, h } = sizeRef.current; + if (t.k > 0 && w && h) { + const x0 = ((0 - t.x) / t.k) * scale + ox; + const y0 = ((0 - t.y) / t.k) * scale + oy; + const x1 = ((w - t.x) / t.k) * scale + ox; + const y1 = ((h - t.y) / t.k) * scale + oy; + ctx.strokeStyle = "rgba(56, 189, 248, 0.8)"; + ctx.lineWidth = 1; + ctx.strokeRect(x0, y0, x1 - x0, y1 - y0); + } + }, []); + + // ----------------------------------------------------------------- loop + + useEffect(() => { + const loop = () => { + const sim = simRef.current; + let active = false; + + const anim = animRef.current; + if (anim) { + const progress = Math.min( + (performance.now() - anim.start) / anim.duration, + 1, + ); + const e = easeCubicOut(progress); + transformRef.current = { + x: anim.from.x + (anim.to.x - anim.from.x) * e, + y: anim.from.y + (anim.to.y - anim.from.y) * e, + k: anim.from.k + (anim.to.k - anim.from.k) * e, + }; + if (progress >= 1) { + animRef.current = null; + propsRef.current.onZoomChange(transformRef.current.k); + } + active = true; + } + + if (sim && layoutRef.current === "force" && sim.tick()) { + active = true; + // Auto-fit once after the initial layout settles. + if (!autoFitRef.current && sim.alpha < 0.3) { + autoFitRef.current = true; + fitView(true); + } + } else if (sim && !autoFitRef.current && sim.nodes.length) { + autoFitRef.current = true; + fitView(false); + } + + if (active || needsDrawRef.current) { + needsDrawRef.current = false; + draw(); + } + rafRef.current = requestAnimationFrame(loop); + }; + rafRef.current = requestAnimationFrame(loop); + return () => cancelAnimationFrame(rafRef.current); + }, [draw, fitView]); + + // --------------------------------------------------------------- resize + + useEffect(() => { + const container = containerRef.current; + const canvas = canvasRef.current; + if (!container || !canvas) return; + let timer: number | undefined; + const apply = () => { + const rect = container.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + sizeRef.current = { w: rect.width, h: rect.height }; + canvas.width = Math.max(1, Math.round(rect.width * dpr)); + canvas.height = Math.max(1, Math.round(rect.height * dpr)); + if (transformRef.current.k === 0) { + setTransform({ x: rect.width / 2, y: rect.height / 2, k: 0.9 }); + } + requestDraw(); + }; + apply(); + const observer = new ResizeObserver(() => { + window.clearTimeout(timer); + timer = window.setTimeout(apply, 120); + }); + observer.observe(container); + return () => { + window.clearTimeout(timer); + observer.disconnect(); + }; + }, [requestDraw, setTransform]); + + // --------------------------------------------------------------- events + + const screenToWorld = (sx: number, sy: number) => { + const t = transformRef.current; + return { x: (sx - t.x) / t.k, y: (sy - t.y) / t.k }; + }; + + const hitTest = (wx: number, wy: number): SimNode | null => { + const sim = simRef.current; + if (!sim) return null; + // Topmost (drawn last) first. + for (let i = sim.nodes.length - 1; i >= 0; i--) { + const n = sim.nodes[i]; + const dx = wx - n.x; + const dy = wy - n.y; + const r = n.radius + 3 / (transformRef.current.k || 1); + if (dx * dx + dy * dy <= r * r) return n; + } + return null; + }; + + const localPoint = (e: { clientX: number; clientY: number }) => { + const rect = canvasRef.current!.getBoundingClientRect(); + return { sx: e.clientX - rect.left, sy: e.clientY - rect.top }; + }; + + const setHover = (node: SimNode | null, e?: React.PointerEvent) => { + if (hoverRef.current?.id !== node?.id) { + hoverRef.current = node; + requestDraw(); + } + const canvas = canvasRef.current; + if (canvas) canvas.style.cursor = node ? "pointer" : "grab"; + if (node && e) { + propsRef.current.onTooltip({ + x: e.clientX, + y: e.clientY, + node: node.node, + kind: node.kind, + degree: node.degree, + }); + } else { + propsRef.current.onTooltip(null); + } + }; + + const handlePointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) return; + const { sx, sy } = localPoint(e); + const { x: wx, y: wy } = screenToWorld(sx, sy); + const node = hitTest(wx, wy); + animRef.current = null; + dragRef.current = { + pointerId: e.pointerId, + node, + startX: sx, + startY: sy, + originX: transformRef.current.x, + originY: transformRef.current.y, + moved: false, + }; + if (node) { + node.fx = node.x; + node.fy = node.y; + } + e.currentTarget.setPointerCapture(e.pointerId); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + const { sx, sy } = localPoint(e); + const drag = dragRef.current; + 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 (drag.node) { + const world = screenToWorld(sx, sy); + drag.node.fx = world.x; + drag.node.fy = world.y; + if (layoutRef.current === "force") simRef.current?.reheat(0.3); + propsRef.current.onTooltip(null); + } else { + transformRef.current = { + ...transformRef.current, + x: drag.originX + dx, + y: drag.originY + dy, + }; + } + requestDraw(); + return; + } + const world = screenToWorld(sx, sy); + setHover(hitTest(world.x, world.y), e); + }; + + const handlePointerUp = (e: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== e.pointerId) return; + dragRef.current = null; + if (!drag.moved) { + // Plain click: select node / clear selection on empty space. + if (drag.node) { + drag.node.fx = null; + drag.node.fy = null; + propsRef.current.onSelectNode(drag.node.id); + } else { + propsRef.current.onSelectNode(null); + } + requestDraw(); + } + // A real drag leaves the node pinned at its drop position. + }; + + const handleDoubleClick = (e: React.MouseEvent) => { + const { sx, sy } = localPoint(e); + const world = screenToWorld(sx, sy); + const node = hitTest(world.x, world.y); + if (node) { + propsRef.current.onSelectNode(node.id); + focusNode(node.id); + } + }; + + const handlePointerLeave = () => { + setHover(null); + }; + + // Wheel zoom needs a non-passive listener to preventDefault. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const rect = canvas.getBoundingClientRect(); + zoomBy( + Math.exp(-e.deltaY * 0.0012), + e.clientX - rect.left, + e.clientY - rect.top, + ); + }; + canvas.addEventListener("wheel", onWheel, { passive: false }); + return () => canvas.removeEventListener("wheel", onWheel); + }, [zoomBy]); + + const minimapVisible = useMemo(() => props.nodes.length > 0, [props.nodes]); + + return ( +
+ + {props.toolbar ? ( +
{props.toolbar}
+ ) : null} + {minimapVisible ? ( + + ) : null} + {props.overlay} +
+ ); + }, +); diff --git a/frontend-web/src/components/knowledge-graph/GraphDetailPanel.tsx b/frontend-web/src/components/knowledge-graph/GraphDetailPanel.tsx new file mode 100644 index 00000000..e265f106 --- /dev/null +++ b/frontend-web/src/components/knowledge-graph/GraphDetailPanel.tsx @@ -0,0 +1,210 @@ +import { Crosshair, EyeOff, FileText, Loader2, MousePointerClick } from "lucide-react"; + +import type { KnowledgeNote } from "@/core/knowledge/types"; + +import type { GraphNode } from "./graphTypes"; +import { + getEdgeInfo, + getNodeKind, + getNodeTitle, + NODE_KIND_META, +} from "./graphUtils"; + +const SOURCE_TYPE_ZH: Record = { + thread: "对话沉淀", + search: "搜索沉淀", + tool: "工具结果", + file: "文件导入", + manual: "手动创建", + report: "报告", +}; + +const STATUS_ZH: Record = { + draft: "草稿", + approved: "已审核", + archived: "已归档", +}; + +export interface NeighborItem { + node: GraphNode; + relation: string; +} + +interface GraphDetailPanelProps { + node: GraphNode | null; + degree: number; + neighbors: NeighborItem[]; + /** Note detail (only for note-type nodes; undefined while absent/loading). */ + note: KnowledgeNote | undefined; + noteLoading: boolean; + onViewSource: (noteId: string) => void; + onLocate: (id: string) => void; + onHide: (id: string) => void; + onPickNeighbor: (id: string) => void; +} + +function formatTime(value: string | null | undefined): string { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString("zh-CN", { hour12: false }); +} + +export function GraphDetailPanel(props: GraphDetailPanelProps) { + const { node } = props; + + return ( + + ); +} + +function DetailBody(props: GraphDetailPanelProps & { node: GraphNode }) { + const { node, note } = props; + const kind = getNodeKind(node); + const kindMeta = NODE_KIND_META[kind]; + const isNote = (node.type || "").toLowerCase() === "note"; + + return ( +
+

{getNodeTitle(node)}

+
+ + + {kindMeta.label} + {node.category && node.category !== kindMeta.label + ? ` · ${node.category}` + : ""} + + + 度数 {props.degree} + +
+ + {props.noteLoading ? ( +
+ 加载笔记详情... +
+ ) : null} + + {note?.summary ?

{note.summary}

: null} + + {note?.tags?.length ? ( +
+ {note.tags.map((tag) => ( + + #{tag} + + ))} +
+ ) : null} + + {note ? ( +
+ 来源 + + {SOURCE_TYPE_ZH[note.source_type] ?? note.source_type} + + 状态 + + {STATUS_ZH[note.status] ?? note.status} + + 创建时间 + {formatTime(note.created_at)} + 更新时间 + {formatTime(note.updated_at)} + {note.created_by_name ? ( + <> + 创建人 + {note.created_by_name} + + ) : null} +
+ ) : null} + +
+ {isNote ? ( + + ) : null} + + +
+ +

+ 关联节点({props.neighbors.length}) +

+ {props.neighbors.length === 0 ? ( +
该节点暂无可见的关联节点
+ ) : ( +
+ {props.neighbors.map(({ node: neighbor, relation }) => { + const neighborKind = NODE_KIND_META[getNodeKind(neighbor)]; + const relationInfo = getEdgeInfo(relation); + return ( +
props.onPickNeighbor(neighbor.id)} + > + + {getNodeTitle(neighbor)} + + {relationInfo.zh} + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend-web/src/components/knowledge-graph/GraphFooter.tsx b/frontend-web/src/components/knowledge-graph/GraphFooter.tsx new file mode 100644 index 00000000..7faa1051 --- /dev/null +++ b/frontend-web/src/components/knowledge-graph/GraphFooter.tsx @@ -0,0 +1,57 @@ +import type { NodeKind } from "./graphTypes"; +import { getEdgeInfo, NODE_KIND_META, NODE_KIND_ORDER } from "./graphUtils"; + +interface GraphFooterProps { + nodeKindCounts: Map; + edgeLabels: string[]; + zoomLevel: number; + selectedTitle: string | null; + visibleNodes: number; + visibleEdges: number; +} + +export function GraphFooter(props: GraphFooterProps) { + const kinds = NODE_KIND_ORDER.filter((k) => (props.nodeKindCounts.get(k) ?? 0) > 0); + + return ( +
+
+ {kinds.map((kind) => ( + + + {NODE_KIND_META[kind].label} + + ))} + {props.edgeLabels.slice(0, 6).map((label) => { + const info = getEdgeInfo(label); + return ( + + + {info.zh} + + ); + })} +
+
+ {props.selectedTitle ? ( + + 选中:{props.selectedTitle} + + ) : null} + + 显示 {props.visibleNodes} 节点 · {props.visibleEdges} 关系 + + 缩放 {Math.round(props.zoomLevel * 100)}% +
+
+ ); +} diff --git a/frontend-web/src/components/knowledge-graph/GraphHeader.tsx b/frontend-web/src/components/knowledge-graph/GraphHeader.tsx new file mode 100644 index 00000000..6c4a1159 --- /dev/null +++ b/frontend-web/src/components/knowledge-graph/GraphHeader.tsx @@ -0,0 +1,147 @@ +import { + ArrowLeft, + Download, + Loader2, + Maximize2, + PanelLeft, + PanelRight, + RefreshCw, + Scan, + Shuffle, + ZoomIn, + ZoomOut, +} from "lucide-react"; + +interface GraphHeaderProps { + nodeCount: number; + edgeCount: number; + totalNodes: number; + totalEdges: number; + refreshing: boolean; + showSidebar: boolean; + showDetail: boolean; + onBack: () => void; + onRelayout: () => void; + onFitView: () => void; + onZoomIn: () => void; + onZoomOut: () => void; + onExport: () => void; + onFullscreen: () => void; + onRefresh: () => void; + onToggleSidebar: () => void; + onToggleDetail: () => void; +} + +export function GraphHeader(props: GraphHeaderProps) { + const filteredNodes = + props.nodeCount === props.totalNodes + ? `${props.totalNodes}` + : `${props.nodeCount}/${props.totalNodes}`; + const filteredEdges = + props.edgeCount === props.totalEdges + ? `${props.totalEdges}` + : `${props.edgeCount}/${props.totalEdges}`; + + return ( +
+
+ +
+

知识图谱

+ 知识关系可视化 +
+
+ {filteredNodes} 个节点 + + {filteredEdges} 条关系 + +
+
+
+ + + + + + + +
+ + +
+
+ ); +} diff --git a/frontend-web/src/components/knowledge-graph/GraphSidebar.tsx b/frontend-web/src/components/knowledge-graph/GraphSidebar.tsx new file mode 100644 index 00000000..1c0f8474 --- /dev/null +++ b/frontend-web/src/components/knowledge-graph/GraphSidebar.tsx @@ -0,0 +1,163 @@ +import { Search } from "lucide-react"; + +import type { GraphNode, GraphStats, NodeKind } from "./graphTypes"; +import { + getEdgeInfo, + getNodeTitle, + NODE_KIND_META, + NODE_KIND_ORDER, +} from "./graphUtils"; + +interface GraphSidebarProps { + searchKeyword: string; + searchMatchCount: number | null; + nodeKindCounts: Map; + edgeLabelCounts: Map; + disabledNodeKinds: Set; + disabledEdgeLabels: Set; + stats: GraphStats; + topNodes: Array<{ node: GraphNode; degree: number }>; + hiddenCount: number; + onSearchChange: (keyword: string) => void; + onSearchEnter: () => void; + onToggleNodeKind: (kind: NodeKind) => void; + onToggleEdgeLabel: (label: string) => void; + onRestoreHidden: () => void; + onPickNode: (id: string) => void; +} + +export function GraphSidebar(props: GraphSidebarProps) { + const kinds = NODE_KIND_ORDER.filter((k) => (props.nodeKindCounts.get(k) ?? 0) > 0); + const edgeLabels = [...props.edgeLabelCounts.entries()].sort((a, b) => b[1] - a[1]); + + return ( + + ); +} diff --git a/frontend-web/src/components/knowledge-graph/GraphTooltip.tsx b/frontend-web/src/components/knowledge-graph/GraphTooltip.tsx new file mode 100644 index 00000000..50e5bf33 --- /dev/null +++ b/frontend-web/src/components/knowledge-graph/GraphTooltip.tsx @@ -0,0 +1,29 @@ +import type { TooltipState } from "./graphTypes"; +import { getNodeTitle, NODE_KIND_META } from "./graphUtils"; + +interface GraphTooltipProps { + tooltip: TooltipState | null; +} + +export function GraphTooltip({ tooltip }: GraphTooltipProps) { + if (!tooltip) return null; + const meta = NODE_KIND_META[tooltip.kind]; + // Keep the tooltip inside the viewport. + const left = Math.min(tooltip.x + 14, window.innerWidth - 336); + const top = Math.min(tooltip.y + 14, window.innerHeight - 120); + + return ( +
+
{getNodeTitle(tooltip.node)}
+
+ + + {meta.label} + + {tooltip.node.category ? · {tooltip.node.category} : null} + · 度数 {tooltip.degree} +
+
单击查看详情 · 双击聚焦 · 拖拽固定位置
+
+ ); +} diff --git a/frontend-web/src/components/knowledge-graph/graphStyles.css b/frontend-web/src/components/knowledge-graph/graphStyles.css new file mode 100644 index 00000000..3ac82f48 --- /dev/null +++ b/frontend-web/src/components/knowledge-graph/graphStyles.css @@ -0,0 +1,779 @@ +/* Knowledge-graph workbench — dark, professional analysis-tool styling. */ + +.kg-page { + --kg-bg: #0b1020; + --kg-panel: rgba(15, 23, 42, 0.78); + --kg-panel-solid: #0f172a; + --kg-border: rgba(148, 163, 184, 0.18); + --kg-text-main: #e5e7eb; + --kg-text-secondary: #94a3b8; + --kg-text-muted: #64748b; + --kg-primary: #38bdf8; + --kg-primary-soft: rgba(56, 189, 248, 0.16); + + height: 100%; + min-height: 0; + background: + radial-gradient(circle at 20% 10%, rgba(56, 189, 248, 0.1), transparent 32%), + radial-gradient(circle at 80% 20%, rgba(167, 139, 250, 0.1), transparent 28%), + #0b1020; + color: var(--kg-text-main); + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ------------------------------------------------------------------ header */ + +.kg-header { + height: 56px; + border-bottom: 1px solid var(--kg-border); + background: rgba(15, 23, 42, 0.86); + backdrop-filter: blur(14px); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 18px; + flex-shrink: 0; +} + +.kg-header-left { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.kg-back { + height: 32px; + padding: 0 10px; + border-radius: 8px; + border: none; + background: transparent; + color: var(--kg-text-main); + font-size: 13px; + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + flex-shrink: 0; +} + +.kg-back:hover { + background: rgba(148, 163, 184, 0.12); +} + +.kg-title-block { + display: flex; + align-items: baseline; + gap: 10px; + min-width: 0; +} + +.kg-title { + font-size: 18px; + font-weight: 800; + color: #ffffff; + white-space: nowrap; +} + +.kg-subtitle { + font-size: 12px; + color: var(--kg-text-secondary); + white-space: nowrap; +} + +.kg-stat-pills { + display: flex; + align-items: center; + gap: 8px; +} + +.kg-stat-pill { + height: 28px; + padding: 0 10px; + border-radius: 999px; + background: rgba(56, 189, 248, 0.12); + border: 1px solid rgba(56, 189, 248, 0.28); + color: #bae6fd; + font-size: 12px; + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.kg-stat-pill.kg-stat-pill-violet { + background: rgba(167, 139, 250, 0.12); + border-color: rgba(167, 139, 250, 0.28); + color: #ddd6fe; +} + +.kg-header-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.kg-icon-button { + height: 34px; + min-width: 34px; + padding: 0 8px; + border-radius: 9px; + border: 1px solid var(--kg-border); + background: rgba(15, 23, 42, 0.65); + color: #cbd5e1; + font-size: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + cursor: pointer; + transition: + background 0.15s ease, + color 0.15s ease, + border-color 0.15s ease; +} + +.kg-icon-button:hover { + color: #ffffff; + background: rgba(56, 189, 248, 0.14); + border-color: rgba(56, 189, 248, 0.35); +} + +.kg-icon-button:disabled { + opacity: 0.5; + cursor: default; +} + +.kg-icon-button.kg-icon-button-active { + color: #ffffff; + background: rgba(56, 189, 248, 0.18); + border-color: rgba(56, 189, 248, 0.45); +} + +.kg-header-divider { + width: 1px; + height: 20px; + background: var(--kg-border); + margin: 0 2px; +} + +/* -------------------------------------------------------------------- body */ + +.kg-body { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 280px minmax(0, 1fr) 320px; + gap: 12px; + padding: 12px; +} + +.kg-body.kg-hide-left { + grid-template-columns: minmax(0, 1fr) 320px; +} + +.kg-body.kg-hide-right { + grid-template-columns: 280px minmax(0, 1fr); +} + +.kg-body.kg-hide-left.kg-hide-right { + grid-template-columns: minmax(0, 1fr); +} + +.kg-panel { + background: var(--kg-panel); + border: 1px solid var(--kg-border); + border-radius: 16px; + backdrop-filter: blur(14px); + overflow: hidden; + min-height: 0; + display: flex; + flex-direction: column; +} + +.kg-panel-header { + height: 46px; + padding: 0 16px; + border-bottom: 1px solid rgba(148, 163, 184, 0.14); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.kg-panel-title { + font-size: 14px; + font-weight: 700; + color: var(--kg-text-main); +} + +.kg-panel-content { + padding: 14px; + overflow-y: auto; + min-height: 0; + flex: 1; + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.3) transparent; +} + +/* ----------------------------------------------------------------- sidebar */ + +.kg-search-wrap { + position: relative; +} + +.kg-search-wrap > svg { + position: absolute; + left: 11px; + top: 50%; + transform: translateY(-50%); + color: var(--kg-text-muted); + pointer-events: none; +} + +.kg-search { + height: 36px; + width: 100%; + border-radius: 10px; + border: 1px solid rgba(148, 163, 184, 0.2); + background: rgba(2, 6, 23, 0.45); + color: var(--kg-text-main); + padding: 0 12px 0 32px; + font-size: 13px; + outline: none; +} + +.kg-search::placeholder { + color: var(--kg-text-muted); +} + +.kg-search:focus { + border-color: rgba(56, 189, 248, 0.55); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.1); +} + +.kg-search-hint { + margin-top: 6px; + font-size: 12px; + color: var(--kg-text-muted); +} + +.kg-search-hint.kg-search-hint-warn { + color: #fbbf24; +} + +.kg-filter-section { + margin-top: 18px; +} + +.kg-filter-title { + font-size: 12px; + font-weight: 700; + color: var(--kg-text-secondary); + margin-bottom: 8px; + letter-spacing: 0.02em; +} + +.kg-check-row { + min-height: 30px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: #cbd5e1; + font-size: 13px; + cursor: pointer; + border-radius: 8px; + padding: 0 6px; + user-select: none; +} + +.kg-check-row:hover { + background: rgba(148, 163, 184, 0.08); +} + +.kg-check-row-main { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.kg-check-row input[type="checkbox"] { + accent-color: #38bdf8; + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.kg-check-row .kg-legend-dot { + flex-shrink: 0; +} + +.kg-check-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kg-count { + color: var(--kg-text-muted); + font-size: 12px; + flex-shrink: 0; +} + +.kg-stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.kg-stat-card { + border-radius: 10px; + border: 1px solid rgba(148, 163, 184, 0.12); + background: rgba(2, 6, 23, 0.35); + padding: 8px 10px; +} + +.kg-stat-card-value { + font-size: 16px; + font-weight: 800; + color: #ffffff; +} + +.kg-stat-card-label { + font-size: 11px; + color: var(--kg-text-muted); + margin-top: 2px; +} + +.kg-rank-item { + display: flex; + gap: 8px; + padding: 8px 6px; + border-bottom: 1px solid rgba(148, 163, 184, 0.08); + cursor: pointer; + border-radius: 8px; +} + +.kg-rank-item:hover { + background: rgba(56, 189, 248, 0.08); +} + +.kg-rank-index { + width: 20px; + color: var(--kg-primary); + font-weight: 700; + font-size: 12px; + flex-shrink: 0; +} + +.kg-rank-title { + flex: 1; + color: #cbd5e1; + font-size: 12px; + line-height: 1.5; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.kg-rank-degree { + color: var(--kg-text-muted); + font-size: 11px; + flex-shrink: 0; +} + +.kg-text-button { + border: none; + background: transparent; + color: var(--kg-primary); + font-size: 12px; + cursor: pointer; + padding: 0; +} + +.kg-text-button:hover { + text-decoration: underline; +} + +/* ------------------------------------------------------------------ canvas */ + +.kg-canvas-wrap { + position: relative; + background: + linear-gradient(rgba(148, 163, 184, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(148, 163, 184, 0.04) 1px, transparent 1px), + radial-gradient(circle at 50% 40%, rgba(56, 189, 248, 0.08), transparent 42%), + rgba(2, 6, 23, 0.5); + background-size: + 28px 28px, + 28px 28px, + auto, + auto; + border: 1px solid var(--kg-border); + border-radius: 18px; + overflow: hidden; + min-width: 0; + min-height: 0; +} + +.kg-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; + touch-action: none; +} + +.kg-canvas-toolbar { + position: absolute; + top: 14px; + left: 14px; + z-index: 10; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.kg-floating-pill { + height: 32px; + padding: 0 6px; + border-radius: 999px; + background: rgba(15, 23, 42, 0.82); + border: 1px solid var(--kg-border); + color: #cbd5e1; + font-size: 12px; + display: inline-flex; + align-items: center; + gap: 2px; + backdrop-filter: blur(12px); +} + +.kg-pill-label { + padding: 0 6px; + color: var(--kg-text-muted); +} + +.kg-pill-button { + height: 24px; + padding: 0 10px; + border-radius: 999px; + border: none; + background: transparent; + color: #cbd5e1; + font-size: 12px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.kg-pill-button:hover { + color: #ffffff; +} + +.kg-pill-button.kg-pill-button-active { + background: rgba(56, 189, 248, 0.18); + color: #ffffff; +} + +.kg-minimap { + position: absolute; + right: 14px; + bottom: 14px; + width: 180px; + height: 120px; + border-radius: 12px; + background: rgba(15, 23, 42, 0.78); + border: 1px solid var(--kg-border); + overflow: hidden; + z-index: 10; + pointer-events: none; +} + +.kg-canvas-status { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + text-align: center; + color: var(--kg-text-secondary); + padding: 32px; + z-index: 5; +} + +.kg-canvas-status-title { + font-size: 16px; + font-weight: 700; + color: var(--kg-text-main); +} + +.kg-canvas-status-desc { + font-size: 13px; + line-height: 1.8; + max-width: 360px; + color: var(--kg-text-muted); +} + +/* ----------------------------------------------------------------- tooltip */ + +.kg-tooltip { + position: fixed; + z-index: 999; + max-width: 320px; + padding: 12px; + border-radius: 12px; + background: rgba(15, 23, 42, 0.94); + border: 1px solid rgba(56, 189, 248, 0.25); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35); + color: var(--kg-text-main); + pointer-events: none; + backdrop-filter: blur(14px); +} + +.kg-tooltip-title { + font-size: 13px; + font-weight: 700; + margin-bottom: 6px; + line-height: 1.5; +} + +.kg-tooltip-desc { + font-size: 12px; + color: var(--kg-text-secondary); + line-height: 1.6; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.kg-tooltip-hint { + margin-top: 8px; + font-size: 11px; + color: var(--kg-text-muted); +} + +/* ------------------------------------------------------------------ detail */ + +.kg-detail-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + color: var(--kg-text-muted); + padding: 32px; + gap: 10px; + font-size: 13px; + line-height: 1.8; +} + +.kg-detail-empty-title { + color: var(--kg-text-secondary); + font-size: 14px; + font-weight: 600; +} + +.kg-detail-title { + font-size: 17px; + line-height: 1.5; + font-weight: 800; + color: #ffffff; + margin-bottom: 10px; + word-break: break-word; +} + +.kg-detail-type { + display: inline-flex; + height: 24px; + padding: 0 9px; + border-radius: 999px; + font-size: 12px; + align-items: center; + gap: 6px; + margin-right: 6px; + margin-bottom: 12px; +} + +.kg-detail-summary { + color: #cbd5e1; + font-size: 13px; + line-height: 1.8; + margin-bottom: 16px; + word-break: break-word; +} + +.kg-detail-meta { + display: grid; + grid-template-columns: 72px 1fr; + row-gap: 8px; + column-gap: 8px; + font-size: 12px; + margin-bottom: 16px; +} + +.kg-detail-meta-label { + color: var(--kg-text-muted); +} + +.kg-detail-meta-value { + color: #cbd5e1; + word-break: break-word; +} + +.kg-tag-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 16px; +} + +.kg-tag { + height: 24px; + padding: 0 8px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.12); + color: #cbd5e1; + font-size: 12px; + display: inline-flex; + align-items: center; +} + +.kg-detail-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 18px; +} + +.kg-detail-section-title { + font-size: 12px; + font-weight: 700; + color: var(--kg-text-secondary); + margin: 0 0 8px; +} + +.kg-neighbor-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.kg-neighbor-item { + padding: 8px 10px; + border-radius: 10px; + background: rgba(2, 6, 23, 0.35); + border: 1px solid rgba(148, 163, 184, 0.1); + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; +} + +.kg-neighbor-item:hover { + background: rgba(56, 189, 248, 0.1); + border-color: rgba(56, 189, 248, 0.24); +} + +.kg-neighbor-title { + flex: 1; + min-width: 0; + font-size: 12px; + color: #cbd5e1; + line-height: 1.5; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kg-neighbor-relation { + font-size: 11px; + flex-shrink: 0; +} + +/* ------------------------------------------------------------------ footer */ + +.kg-footer { + min-height: 42px; + margin: 0 12px 12px; + border-radius: 14px; + background: var(--kg-panel); + border: 1px solid var(--kg-border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 4px 14px; + color: var(--kg-text-secondary); + font-size: 12px; + flex-shrink: 0; + flex-wrap: wrap; +} + +.kg-legend { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.kg-legend-item { + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.kg-legend-dot { + width: 9px; + height: 9px; + border-radius: 999px; +} + +.kg-legend-line { + width: 20px; + height: 0; + border-top-width: 2px; + border-top-style: solid; + border-radius: 999px; +} + +.kg-footer-status { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + white-space: nowrap; +} + +.kg-footer-selected { + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + color: #bae6fd; +} + +/* -------------------------------------------------------------- responsive */ + +@media (max-width: 1100px) { + .kg-header .kg-button-text { + display: none; + } + + .kg-subtitle { + display: none; + } +} diff --git a/frontend-web/src/components/knowledge-graph/graphTypes.ts b/frontend-web/src/components/knowledge-graph/graphTypes.ts new file mode 100644 index 00000000..3969dd95 --- /dev/null +++ b/frontend-web/src/components/knowledge-graph/graphTypes.ts @@ -0,0 +1,99 @@ +/** Shared types for the knowledge-graph workbench. */ + +/** Raw node as returned by `GET /api/knowledge/graph` (plus compat fields). */ +export interface GraphNode { + id: string; + /** "note" | "entity" on the current backend; kept loose for compat. */ + type?: string; + label?: string; + title?: string; + name?: string; + /** note: source_type (thread/file/manual/...); entity: entity_type. */ + category?: string | null; +} + +/** Raw edge as returned by `GET /api/knowledge/graph` (plus compat fields). */ +export interface GraphEdge { + id?: string; + source: string; + target: string; + /** Relation type: "links" | "related" | LLM relation_type. */ + label?: string; + type?: string; + relation?: string; + weight?: number; + directed?: boolean; +} + +export interface GraphData { + nodes: GraphNode[]; + edges: GraphEdge[]; +} + +/** Normalized visual bucket for a node. */ +export type NodeKind = + | "document" + | "concept" + | "person" + | "org" + | "location" + | "event" + | "tag" + | "other"; + +export type LayoutType = "force" | "circular"; + +/** Style metadata of an edge label. */ +export interface EdgeInfo { + color: string; + zh: string; + dashed: boolean; +} + +/** A node inside the force simulation (world coordinates). */ +export interface SimNode { + id: string; + node: GraphNode; + kind: NodeKind; + degree: number; + radius: number; + color: string; + x: number; + y: number; + vx: number; + vy: number; + fx: number | null; + fy: number | null; +} + +export interface SimEdge { + id: string; + source: SimNode; + target: SimNode; + label: string; + info: EdgeInfo; + weight: number; + distance: number; +} + +export interface Transform { + x: number; + y: number; + k: number; +} + +export interface TooltipState { + /** Viewport (client) coordinates. */ + x: number; + y: number; + node: GraphNode; + kind: NodeKind; + degree: number; +} + +export interface GraphStats { + nodes: number; + edges: number; + avgDegree: number; + largestComponent: number; +} diff --git a/frontend-web/src/components/knowledge-graph/graphUtils.ts b/frontend-web/src/components/knowledge-graph/graphUtils.ts new file mode 100644 index 00000000..c8548b2e --- /dev/null +++ b/frontend-web/src/components/knowledge-graph/graphUtils.ts @@ -0,0 +1,348 @@ +import type { + EdgeInfo, + GraphEdge, + GraphNode, + GraphStats, + NodeKind, + SimEdge, + SimNode, +} from "./graphTypes"; + +// --------------------------------------------------------------------------- +// Node helpers +// --------------------------------------------------------------------------- + +export const getNodeTitle = (node: GraphNode): string => + node.title || node.label || node.name || node.id; + +const KIND_PATTERNS: Array<[NodeKind, RegExp]> = [ + ["person", /^(person|people|human|人物|人名|人员)$/i], + ["org", /^(org|organization|organisation|company|institution|party|组织|机构|公司|团体|政党|国会)$/i], + ["location", /^(location|place|country|city|region|gpe|地点|地区|国家|城市|地理位置)$/i], + ["event", /^(event|事件|活动|会议|战争|选举|讲话)$/i], + ["tag", /^(tag|keyword|标签|关键词)$/i], + ["concept", /^(concept|topic|term|technology|theory|product|project|主题|概念|术语|技术|理论|产品|项目|政策)$/i], +]; + +/** Map a raw node (note source_type / entity entity_type) to a visual bucket. */ +export function getNodeKind(node: GraphNode): NodeKind { + const type = (node.type || "").toLowerCase(); + if (type === "note" || type === "document" || type === "file") return "document"; + const category = (node.category || "").trim(); + if (category) { + for (const [kind, re] of KIND_PATTERNS) { + if (re.test(category)) return kind; + } + } + // Unmatched LLM entity types still read better as "concept" than as a grey blob. + if (type === "entity") return category ? "other" : "concept"; + return "other"; +} + +export const NODE_KIND_META: Record = { + document: { label: "文档", color: "#38bdf8" }, + concept: { label: "主题", color: "#a78bfa" }, + person: { label: "人物", color: "#fbbf24" }, + org: { label: "组织", color: "#2dd4bf" }, + location: { label: "地点", color: "#34d399" }, + event: { label: "事件", color: "#fb7185" }, + tag: { label: "标签", color: "#a3e635" }, + other: { label: "其他", color: "#94a3b8" }, +}; + +/** Stable ordering for filters / legend. */ +export const NODE_KIND_ORDER: NodeKind[] = [ + "document", + "concept", + "person", + "org", + "location", + "event", + "tag", + "other", +]; + +export const getNodeRadius = (degree: number): number => + Math.min(30, 9 + Math.sqrt(Math.max(degree, 0)) * 4); + +export function truncateLabel(text: string, max = 14): string { + return text.length > max ? `${text.slice(0, max)}…` : text; +} + +// --------------------------------------------------------------------------- +// Edge helpers +// --------------------------------------------------------------------------- + +export const getEdgeLabel = (edge: GraphEdge): string => + edge.label || edge.type || edge.relation || "related"; + +const KNOWN_EDGE_INFO: Record = { + related: { color: "#94a3b8", zh: "相关", dashed: false }, + links: { color: "#a78bfa", zh: "引用", dashed: true }, + references: { color: "#a78bfa", zh: "引用", dashed: true }, + contains: { color: "#34d399", zh: "包含", dashed: false }, + belongs_to: { color: "#2dd4bf", zh: "属于", dashed: false }, + similar: { color: "#fbbf24", zh: "相似", dashed: true }, + mentions: { color: "#38bdf8", zh: "提及", dashed: false }, +}; + +const EDGE_PALETTE = [ + "#38bdf8", + "#a78bfa", + "#34d399", + "#fbbf24", + "#fb7185", + "#2dd4bf", + "#fb923c", + "#e879f9", +]; + +function hashString(text: string): number { + let h = 0; + for (let i = 0; i < text.length; i++) { + h = (h * 31 + text.charCodeAt(i)) | 0; + } + return Math.abs(h); +} + +/** Color / display name / line style of an edge label (stable for unknown labels). */ +export function getEdgeInfo(label: string): EdgeInfo { + const known = KNOWN_EDGE_INFO[label.toLowerCase()]; + if (known) return known; + return { + color: EDGE_PALETTE[hashString(label) % EDGE_PALETTE.length], + zh: label, + dashed: false, + }; +} + +export function getLinkDistance(label: string): number { + switch (label.toLowerCase()) { + case "contains": + return 100; + case "links": + case "references": + return 130; + case "similar": + return 170; + default: + return 145; + } +} + +// --------------------------------------------------------------------------- +// Graph statistics +// --------------------------------------------------------------------------- + +export function computeDegrees( + nodes: GraphNode[], + edges: GraphEdge[], +): Map { + const degrees = new Map(); + for (const n of nodes) degrees.set(n.id, 0); + for (const e of edges) { + if (degrees.has(e.source)) degrees.set(e.source, (degrees.get(e.source) ?? 0) + 1); + if (degrees.has(e.target)) degrees.set(e.target, (degrees.get(e.target) ?? 0) + 1); + } + return degrees; +} + +export function computeGraphStats( + nodes: GraphNode[], + edges: GraphEdge[], +): GraphStats { + const adjacency = new Map(); + for (const n of nodes) adjacency.set(n.id, []); + let validEdges = 0; + for (const e of edges) { + if (!adjacency.has(e.source) || !adjacency.has(e.target)) continue; + validEdges += 1; + adjacency.get(e.source)!.push(e.target); + adjacency.get(e.target)!.push(e.source); + } + // Largest connected component via BFS. + const visited = new Set(); + let largest = 0; + for (const n of nodes) { + if (visited.has(n.id)) continue; + let size = 0; + const queue = [n.id]; + visited.add(n.id); + while (queue.length) { + const cur = queue.pop()!; + size += 1; + for (const next of adjacency.get(cur) ?? []) { + if (!visited.has(next)) { + visited.add(next); + queue.push(next); + } + } + } + largest = Math.max(largest, size); + } + return { + nodes: nodes.length, + edges: validEdges, + avgDegree: nodes.length ? (validEdges * 2) / nodes.length : 0, + largestComponent: largest, + }; +} + +// --------------------------------------------------------------------------- +// Layouts +// --------------------------------------------------------------------------- + +const GOLDEN_ANGLE = 2.3999632297286533; + +/** Phyllotaxis seeding around the origin — hubs (high degree) near the center. */ +export function seedPositions(nodes: SimNode[]): void { + const sorted = [...nodes].sort((a, b) => b.degree - a.degree); + sorted.forEach((n, i) => { + const r = 22 * Math.sqrt(i + 0.5); + const angle = i * GOLDEN_ANGLE; + n.x = r * Math.cos(angle); + n.y = r * Math.sin(angle); + n.vx = 0; + n.vy = 0; + n.fx = null; + n.fy = null; + }); +} + +/** Static circular layout, grouped by kind then degree. */ +export function applyCircularLayout(nodes: SimNode[]): void { + const sorted = [...nodes].sort((a, b) => { + const ka = NODE_KIND_ORDER.indexOf(a.kind); + const kb = NODE_KIND_ORDER.indexOf(b.kind); + return ka !== kb ? ka - kb : b.degree - a.degree; + }); + const radius = Math.max(180, (sorted.length * 56) / (2 * Math.PI)); + sorted.forEach((n, i) => { + const angle = (i / sorted.length) * Math.PI * 2 - Math.PI / 2; + n.x = radius * Math.cos(angle); + n.y = radius * Math.sin(angle); + n.vx = 0; + n.vy = 0; + n.fx = null; + n.fy = null; + }); +} + +// --------------------------------------------------------------------------- +// Force simulation (d3-force-like, dependency-free, fine for <500 nodes) +// --------------------------------------------------------------------------- + +export class ForceSimulation { + alpha = 1; + readonly alphaMin = 0.004; + private readonly alphaDecay = 0.0228; + private readonly velocityDecay = 0.38; + private readonly chargeStrength = -420; + private readonly chargeCutoff2 = 600 * 600; + + constructor( + public nodes: SimNode[], + public edges: SimEdge[], + ) {} + + reheat(alpha = 0.9): void { + this.alpha = Math.max(this.alpha, alpha); + } + + /** Advance one step; returns false when the layout has cooled down. */ + tick(): boolean { + if (this.alpha < this.alphaMin) return false; + this.alpha += (0 - this.alpha) * this.alphaDecay; + const a = this.alpha; + const { nodes, edges } = this; + + // Link spring force. + for (const e of edges) { + const s = e.source; + const t = e.target; + let dx = t.x - s.x; + let dy = t.y - s.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1e-3; + const f = ((dist - e.distance) / dist) * 0.45 * a; + dx *= f; + dy *= f; + t.vx -= dx * 0.5; + t.vy -= dy * 0.5; + s.vx += dx * 0.5; + 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; + } + } + + // Gentle pull toward the origin keeps disconnected clusters on screen. + for (const n of nodes) { + n.vx += (0 - n.x) * 0.05 * a; + 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) { + if (n.fx != null && n.fy != null) { + n.x = n.fx; + n.y = n.fy; + n.vx = 0; + n.vy = 0; + } else { + n.vx *= 1 - this.velocityDecay; + n.vy *= 1 - this.velocityDecay; + n.x += n.vx; + n.y += n.vy; + } + } + return true; + } +} diff --git a/frontend-web/src/components/wiki/Breadcrumb.tsx b/frontend-web/src/components/wiki/Breadcrumb.tsx new file mode 100644 index 00000000..f8120471 --- /dev/null +++ b/frontend-web/src/components/wiki/Breadcrumb.tsx @@ -0,0 +1,25 @@ +import { ChevronRight } from "lucide-react"; +import { Fragment } from "react"; + +/** Sticky breadcrumb above the reading area: folders + current document title. */ +export function Breadcrumb({ + segments, + current, +}: { + segments: string[]; + current: string; +}) { + return ( +
+ {segments.map((segment, i) => ( + + {segment} + + + ))} + + {current} + +
+ ); +} diff --git a/frontend-web/src/components/wiki/DocumentToolbar.tsx b/frontend-web/src/components/wiki/DocumentToolbar.tsx new file mode 100644 index 00000000..e2873347 --- /dev/null +++ b/frontend-web/src/components/wiki/DocumentToolbar.tsx @@ -0,0 +1,111 @@ +import { Download, Pencil, Save, Trash2, X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function ToolbarButton({ + icon, + label, + onClick, + disabled, + danger, +}: { + icon: React.ReactNode; + label: string; + onClick: () => void; + disabled?: boolean; + danger?: boolean; +}) { + return ( + + ); +} + +/** Document toolbar: view badge + char count + updated time on the left, actions on the right. */ +export function DocumentToolbar({ + charCount, + updatedAt, + statusLabel, + editing, + saving, + archiving, + onEdit, + onCancelEdit, + onSave, + onExport, + onArchive, +}: { + charCount: number; + updatedAt: string; + statusLabel: string; + editing: boolean; + saving: boolean; + archiving: boolean; + onEdit: () => void; + onCancelEdit: () => void; + onSave: () => void; + onExport: () => void; + onArchive: () => void; +}) { + return ( +
+ + {editing ? "MARKDOWN EDIT" : "MARKDOWN VIEW"} + + + {statusLabel} + + {charCount.toLocaleString()} 字符 + 更新时间:{updatedAt} + +
+ {editing ? ( + <> + } + label="取消" + onClick={onCancelEdit} + /> + } + label={saving ? "保存中..." : "保存"} + onClick={onSave} + disabled={saving} + /> + + ) : ( + <> + } + label="编辑" + onClick={onEdit} + /> + } + label="导出" + onClick={onExport} + /> + } + label="归档" + onClick={onArchive} + disabled={archiving} + danger + /> + + )} +
+
+ ); +} diff --git a/frontend-web/src/components/wiki/EmptyDocumentState.tsx b/frontend-web/src/components/wiki/EmptyDocumentState.tsx new file mode 100644 index 00000000..f0a18ccf --- /dev/null +++ b/frontend-web/src/components/wiki/EmptyDocumentState.tsx @@ -0,0 +1,18 @@ +/** Shown in the reading column when no document is selected. */ +export function EmptyDocumentState() { + return ( +
+
+
📚
+
+ 选择一篇文档开始阅读 +
+
+ 你可以从左侧目录树中选择知识文档, +
+ 也可以导入 Markdown、JSON、TXT 或文件夹来构建 LLM Wiki。 +
+
+
+ ); +} diff --git a/frontend-web/src/components/wiki/KnowledgeSidebar.tsx b/frontend-web/src/components/wiki/KnowledgeSidebar.tsx new file mode 100644 index 00000000..3d6c3369 --- /dev/null +++ b/frontend-web/src/components/wiki/KnowledgeSidebar.tsx @@ -0,0 +1,144 @@ +import { Loader2, Search, SlidersHorizontal } from "lucide-react"; +import { useState } from "react"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { KnowledgeNote } from "@/core/knowledge/types"; +import { cn } from "@/lib/utils"; + +import { KnowledgeTree } from "./KnowledgeTree"; +import type { TreeNode } from "./wikiTypes"; +import { SOURCE_TYPE_LABELS } from "./wikiUtils"; + +interface KnowledgeSidebarProps { + keyword: string; + onKeywordChange: (v: string) => void; + sourceType: string; + onSourceTypeChange: (v: string) => void; + status: string; + onStatusChange: (v: string) => void; + tree: TreeNode[]; + expandedIds: Set; + onToggleFolder: (id: string) => void; + selectedId?: string; + onSelect: (note: KnowledgeNote) => void; + onExportNote: (note: KnowledgeNote) => void; + total: number; + isLoading: boolean; + className?: string; +} + +/** Left column: search + advanced filters + directory tree + footer stats. */ +export function KnowledgeSidebar(props: KnowledgeSidebarProps) { + const [filtersOpen, setFiltersOpen] = useState(false); + const activeFilters = + (props.sourceType !== "all" ? 1 : 0) + (props.status !== "all" ? 1 : 0); + const searching = props.keyword.trim().length > 0; + + return ( + + ); +} diff --git a/frontend-web/src/components/wiki/KnowledgeTree.tsx b/frontend-web/src/components/wiki/KnowledgeTree.tsx new file mode 100644 index 00000000..3b70882d --- /dev/null +++ b/frontend-web/src/components/wiki/KnowledgeTree.tsx @@ -0,0 +1,131 @@ +import { ChevronRight, FileText, Folder, FolderOpen, MoreHorizontal } from "lucide-react"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { KnowledgeNote } from "@/core/knowledge/types"; +import { cn } from "@/lib/utils"; + +import type { TreeNode } from "./wikiTypes"; + +interface KnowledgeTreeProps { + nodes: TreeNode[]; + selectedId?: string; + expandedIds: Set; + /** When searching, render every folder open regardless of expandedIds. */ + forceExpand?: boolean; + onToggleFolder: (id: string) => void; + onSelect: (note: KnowledgeNote) => void; + onExportNote: (note: KnowledgeNote) => void; +} + +function TreeRow(props: KnowledgeTreeProps & { node: TreeNode; depth: number }) { + const { node, depth, selectedId, expandedIds, forceExpand } = props; + const indent = { paddingLeft: `${8 + depth * 14}px` }; + + if (node.type === "folder") { + const open = forceExpand || expandedIds.has(node.id); + return ( +
+
props.onToggleFolder(node.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") props.onToggleFolder(node.id); + }} + style={indent} + className="group flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-lg pr-2 text-sm font-semibold text-slate-600 hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-800" + > + + {open ? ( + + ) : ( + + )} + {node.name} + + {node.children?.length ?? 0} + +
+ {open && node.children ? ( +
+ {node.children.map((child) => ( + + ))} +
+ ) : null} +
+ ); + } + + const active = node.id === selectedId; + return ( +
node.item && props.onSelect(node.item)} + onKeyDown={(e) => { + if ((e.key === "Enter" || e.key === " ") && node.item) props.onSelect(node.item); + }} + style={indent} + className={cn( + "group flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-lg border-l-[3px] border-transparent pr-1 text-sm", + active + ? "border-red-600 bg-red-50 font-semibold text-red-600 dark:bg-red-500/10 dark:text-red-400" + : "text-slate-500 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800", + )} + > + + + {node.name} + + {node.item ? ( + + + + + e.stopPropagation()}> + props.onSelect(node.item!)}> + 打开文档 + + props.onExportNote(node.item!)}> + 导出 Markdown + + + + ) : null} +
+ ); +} + +/** Recursive folder/document tree for the wiki sidebar. */ +export function KnowledgeTree(props: KnowledgeTreeProps) { + return ( +
+ {props.nodes.map((node) => ( + + ))} +
+ ); +} diff --git a/frontend-web/src/components/wiki/MarkdownViewer.tsx b/frontend-web/src/components/wiki/MarkdownViewer.tsx new file mode 100644 index 00000000..318c78b5 --- /dev/null +++ b/frontend-web/src/components/wiki/MarkdownViewer.tsx @@ -0,0 +1,49 @@ +import { useEffect, useRef } from "react"; + +import MarkdownRenderer, { + type WikiLinkMap, + type WikiLinkTarget, +} from "@/strategy-components/MarkdownRenderer"; + +import type { TocItem } from "./wikiTypes"; +import "./wiki.css"; + +/** + * Renders the note body via the shared MarkdownRenderer and mounts the toc ids + * onto the rendered h1–h4 elements so outline clicks / scroll-spy can target them. + * extractToc and this component walk headings in the same document order, so a + * positional mapping is sufficient. + */ +export function MarkdownViewer({ + content, + toc, + wikiLinks, + onWikiLinkClick, +}: { + content: string; + toc: TocItem[]; + wikiLinks?: WikiLinkMap; + onWikiLinkClick?: (link: WikiLinkTarget) => void; +}) { + const rootRef = useRef(null); + + useEffect(() => { + const root = rootRef.current; + if (!root) return; + const headings = root.querySelectorAll("h1, h2, h3, h4"); + headings.forEach((el, index) => { + const item = toc[index]; + if (item) el.id = item.id; + }); + }, [content, toc]); + + return ( +
+ +
+ ); +} diff --git a/frontend-web/src/components/wiki/TocPanel.tsx b/frontend-web/src/components/wiki/TocPanel.tsx new file mode 100644 index 00000000..64852726 --- /dev/null +++ b/frontend-web/src/components/wiki/TocPanel.tsx @@ -0,0 +1,65 @@ +import { ListTree } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +import type { TocItem } from "./wikiTypes"; + +const LEVEL_PADDING: Record = { + 1: "pl-0 font-bold", + 2: "pl-3", + 3: "pl-6", + 4: "pl-9", +}; + +/** Right column: outline of the current document with scroll-spy highlighting. */ +export function TocPanel({ + items, + activeId, + onItemClick, + className, +}: { + items: TocItem[]; + activeId: string | null; + onItemClick: (id: string) => void; + className?: string; +}) { + return ( + + ); +} diff --git a/frontend-web/src/components/wiki/WikiContent.tsx b/frontend-web/src/components/wiki/WikiContent.tsx new file mode 100644 index 00000000..5559faf3 --- /dev/null +++ b/frontend-web/src/components/wiki/WikiContent.tsx @@ -0,0 +1,335 @@ +import { ExternalLink, Sparkles } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { toast } from "sonner"; + +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { useKnowledgeActions } from "@/core/knowledge/hooks"; +import type { KnowledgeNote } from "@/core/knowledge/types"; +import { cn } from "@/lib/utils"; +import type { + WikiLinkMap, + WikiLinkTarget, +} from "@/strategy-components/MarkdownRenderer"; + +import { Breadcrumb } from "./Breadcrumb"; +import { DocumentToolbar } from "./DocumentToolbar"; +import { MarkdownViewer } from "./MarkdownViewer"; +import type { TocItem } from "./wikiTypes"; +import { + SOURCE_TYPE_LABELS, + STATUS_LABELS, + downloadMarkdown, + formatDateTime, + noteBreadcrumbSegments, +} from "./wikiUtils"; + +const ENTITY_TYPE_META: Record = { + person: { label: "人物", className: "bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-500/15 dark:text-rose-300 dark:border-rose-500/30" }, + org: { label: "机构", className: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30" }, + product: { label: "产品", className: "bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30" }, + technology: { label: "技术", className: "bg-cyan-50 text-cyan-700 border-cyan-200 dark:bg-cyan-500/15 dark:text-cyan-300 dark:border-cyan-500/30" }, + concept: { label: "概念", className: "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30" }, + region: { label: "地区", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" }, + country: { label: "国家", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" }, +}; + +function entityMeta(type?: string | null) { + return ( + ENTITY_TYPE_META[(type || "").toLowerCase()] ?? { + label: "其他", + className: + "bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-700/40 dark:text-slate-300 dark:border-slate-600", + } + ); +} + +/** Human label for an audit actor: prefer the resolved name (email), else a short id. */ +function actorLabel(name?: string | null, id?: string | null): string { + if (name && name.trim()) return name.trim(); + if (id && id.trim()) return `${id.trim().slice(0, 8)}…`; + return "—"; +} + +function MetaTag({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/** Middle column: breadcrumb + toolbar + markdown reading area (or inline editor). */ +export function WikiContent({ + note, + toc, + wikiLinks, + onWikiLinkClick, + onActiveHeadingChange, +}: { + note: KnowledgeNote; + toc: TocItem[]; + wikiLinks?: WikiLinkMap; + onWikiLinkClick: (link: WikiLinkTarget) => void; + onActiveHeadingChange: (id: string | null) => void; +}) { + const navigate = useNavigate(); + const { update, remove } = useKnowledgeActions(); + const scrollRef = useRef(null); + const tickingRef = useRef(false); + + const [editing, setEditing] = useState(false); + const [draftTitle, setDraftTitle] = useState(note.title); + const [draftTags, setDraftTags] = useState(note.tags.join(", ")); + const [draftContent, setDraftContent] = useState(note.content_md); + const [draftStatus, setDraftStatus] = useState(note.status); + + useEffect(() => { + setEditing(false); + setDraftTitle(note.title); + setDraftTags(note.tags.join(", ")); + setDraftContent(note.content_md); + setDraftStatus(note.status); + scrollRef.current?.scrollTo({ top: 0 }); + onActiveHeadingChange(toc[0]?.id ?? null); + // Re-sync drafts whenever a different note (or fresh server copy) arrives. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [note.id, note.updated_at]); + + const handleScroll = () => { + if (tickingRef.current) return; + tickingRef.current = true; + window.requestAnimationFrame(() => { + tickingRef.current = false; + const container = scrollRef.current; + if (!container) return; + const headings = container.querySelectorAll( + ".wiki-markdown h1[id], .wiki-markdown h2[id], .wiki-markdown h3[id], .wiki-markdown h4[id]", + ); + if (headings.length === 0) return; + const containerTop = container.getBoundingClientRect().top; + let current: string | null = headings[0].id; + for (const el of headings) { + if (el.getBoundingClientRect().top - containerTop <= 96) { + current = el.id; + } else { + break; + } + } + onActiveHeadingChange(current); + }); + }; + + const handleSave = async () => { + try { + await update.mutateAsync({ + noteId: note.id, + data: { + title: draftTitle.trim() || note.title, + content_md: draftContent, + tags: draftTags + .split(/[,,\s]+/) + .map((t) => t.trim()) + .filter(Boolean), + status: draftStatus, + }, + }); + toast.success("已保存"); + setEditing(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "保存失败"); + } + }; + + const handleArchive = async () => { + try { + await remove.mutateAsync({ noteId: note.id }); + toast.success("已归档"); + navigate("/page/workspace/knowledge"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "归档失败"); + } + }; + + return ( +
+ + setEditing(true)} + onCancelEdit={() => setEditing(false)} + onSave={() => void handleSave()} + onExport={() => downloadMarkdown(note)} + onArchive={() => void handleArchive()} + /> + +
+
+ {editing ? ( +
+ setDraftTitle(e.target.value)} + placeholder="标题" + /> +
+ setDraftTags(e.target.value)} + placeholder="标签,逗号分隔" + /> + +
+