feat(knowledge): 知识库 wiki/知识图谱重构,新增发布广场选择弹窗

- 知识库页拆分出 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) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-11 14:52:25 +08:00
1 parent f0aec4cd7f
commit e684de30bd
30 files changed
+4827 -682

No files matched your search

@@ -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<string, number>;
selectedNodeId: string | null;
/** null = no active search; otherwise the set of matching node ids. */
searchMatchIds: Set<string> | 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<GraphCanvasHandle, GraphCanvasProps>(
function GraphCanvas(props, ref) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const minimapRef = useRef<HTMLCanvasElement>(null);
const simRef = useRef<ForceSimulation | null>(null);
const nodeMapRef = useRef<Map<string, SimNode>>(new Map());
const adjacencyRef = useRef<Map<string, Set<string>>>(new Map());
const labelThresholdRef = useRef(3);
const transformRef = useRef<Transform>({ x: 0, y: 0, k: 0 });
const sizeRef = useRef({ w: 0, h: 0 });
const hoverRef = useRef<SimNode | null>(null);
const dragRef = useRef<DragState | null>(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<string, Set<string>>();
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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 (
<div className="kg-canvas-wrap" ref={containerRef}>
<canvas
ref={canvasRef}
className="kg-canvas"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
onDoubleClick={handleDoubleClick}
/>
{props.toolbar ? (
<div className="kg-canvas-toolbar">{props.toolbar}</div>
) : null}
{minimapVisible ? (
<canvas ref={minimapRef} className="kg-minimap" width={180} height={120} />
) : null}
{props.overlay}
</div>
);
},
);
@@ -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<string, string> = {
thread: "对话沉淀",
search: "搜索沉淀",
tool: "工具结果",
file: "文件导入",
manual: "手动创建",
report: "报告",
};
const STATUS_ZH: Record<string, string> = {
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 (
<aside className="kg-panel kg-detail">
<div className="kg-panel-header">
<span className="kg-panel-title">节点详情</span>
</div>
{!node ? (
<div className="kg-detail-empty">
<MousePointerClick size={28} />
<div className="kg-detail-empty-title">选择一个节点查看详情</div>
<div>
点击图谱中的文档、主题或实体节点,
<br />
查看摘要、标签、关联关系和相邻节点。
</div>
</div>
) : (
<DetailBody {...props} node={node} />
)}
</aside>
);
}
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 (
<div className="kg-panel-content">
<h2 className="kg-detail-title">{getNodeTitle(node)}</h2>
<div>
<span
className="kg-detail-type"
style={{
background: `${kindMeta.color}26`,
color: kindMeta.color,
}}
>
<span className="kg-legend-dot" style={{ background: kindMeta.color }} />
{kindMeta.label}
{node.category && node.category !== kindMeta.label
? ` · ${node.category}`
: ""}
</span>
<span
className="kg-detail-type"
style={{ background: "rgba(148, 163, 184, 0.12)", color: "#cbd5e1" }}
>
度数 {props.degree}
</span>
</div>
{props.noteLoading ? (
<div className="kg-detail-summary" style={{ display: "flex", gap: 8 }}>
<Loader2 size={14} className="animate-spin" /> 加载笔记详情...
</div>
) : null}
{note?.summary ? <p className="kg-detail-summary">{note.summary}</p> : null}
{note?.tags?.length ? (
<div className="kg-tag-list">
{note.tags.map((tag) => (
<span key={tag} className="kg-tag">
#{tag}
</span>
))}
</div>
) : null}
{note ? (
<div className="kg-detail-meta">
<span className="kg-detail-meta-label">来源</span>
<span className="kg-detail-meta-value">
{SOURCE_TYPE_ZH[note.source_type] ?? note.source_type}
</span>
<span className="kg-detail-meta-label">状态</span>
<span className="kg-detail-meta-value">
{STATUS_ZH[note.status] ?? note.status}
</span>
<span className="kg-detail-meta-label">创建时间</span>
<span className="kg-detail-meta-value">{formatTime(note.created_at)}</span>
<span className="kg-detail-meta-label">更新时间</span>
<span className="kg-detail-meta-value">{formatTime(note.updated_at)}</span>
{note.created_by_name ? (
<>
<span className="kg-detail-meta-label">创建人</span>
<span className="kg-detail-meta-value">{note.created_by_name}</span>
</>
) : null}
</div>
) : null}
<div className="kg-detail-actions">
{isNote ? (
<button
type="button"
className="kg-icon-button"
onClick={() => props.onViewSource(node.id)}
>
<FileText size={14} /> 查看原文
</button>
) : null}
<button
type="button"
className="kg-icon-button"
onClick={() => props.onLocate(node.id)}
>
<Crosshair size={14} /> 定位节点
</button>
<button
type="button"
className="kg-icon-button"
onClick={() => props.onHide(node.id)}
>
<EyeOff size={14} /> 隐藏节点
</button>
</div>
<h3 className="kg-detail-section-title">
关联节点({props.neighbors.length})
</h3>
{props.neighbors.length === 0 ? (
<div className="kg-search-hint">该节点暂无可见的关联节点</div>
) : (
<div className="kg-neighbor-list">
{props.neighbors.map(({ node: neighbor, relation }) => {
const neighborKind = NODE_KIND_META[getNodeKind(neighbor)];
const relationInfo = getEdgeInfo(relation);
return (
<div
key={`${neighbor.id}:${relation}`}
className="kg-neighbor-item"
onClick={() => props.onPickNeighbor(neighbor.id)}
>
<span
className="kg-legend-dot"
style={{ background: neighborKind.color, flexShrink: 0 }}
/>
<span className="kg-neighbor-title">{getNodeTitle(neighbor)}</span>
<span
className="kg-neighbor-relation"
style={{ color: relationInfo.color }}
>
{relationInfo.zh}
</span>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,57 @@
import type { NodeKind } from "./graphTypes";
import { getEdgeInfo, NODE_KIND_META, NODE_KIND_ORDER } from "./graphUtils";
interface GraphFooterProps {
nodeKindCounts: Map<NodeKind, number>;
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 (
<footer className="kg-footer">
<div className="kg-legend">
{kinds.map((kind) => (
<span key={kind} className="kg-legend-item">
<span
className="kg-legend-dot"
style={{ background: NODE_KIND_META[kind].color }}
/>
{NODE_KIND_META[kind].label}
</span>
))}
{props.edgeLabels.slice(0, 6).map((label) => {
const info = getEdgeInfo(label);
return (
<span key={label} className="kg-legend-item">
<span
className="kg-legend-line"
style={{
borderTopColor: info.color,
borderTopStyle: info.dashed ? "dashed" : "solid",
}}
/>
{info.zh}
</span>
);
})}
</div>
<div className="kg-footer-status">
{props.selectedTitle ? (
<span className="kg-footer-selected" title={props.selectedTitle}>
选中:{props.selectedTitle}
</span>
) : null}
<span>
显示 {props.visibleNodes} 节点 · {props.visibleEdges} 关系
</span>
<span>缩放 {Math.round(props.zoomLevel * 100)}%</span>
</div>
</footer>
);
}
@@ -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 (
<header className="kg-header">
<div className="kg-header-left">
<button type="button" className="kg-back" onClick={props.onBack}>
<ArrowLeft size={15} />
<span className="kg-button-text">返回</span>
</button>
<div className="kg-title-block">
<h1 className="kg-title">知识图谱</h1>
<span className="kg-subtitle">知识关系可视化</span>
</div>
<div className="kg-stat-pills">
<span className="kg-stat-pill">{filteredNodes} 个节点</span>
<span className="kg-stat-pill kg-stat-pill-violet">
{filteredEdges} 条关系
</span>
</div>
</div>
<div className="kg-header-actions">
<button
type="button"
className="kg-icon-button"
title="重新布局"
onClick={props.onRelayout}
>
<Shuffle size={15} />
<span className="kg-button-text">重新布局</span>
</button>
<button
type="button"
className="kg-icon-button"
title="适应视图"
onClick={props.onFitView}
>
<Scan size={15} />
<span className="kg-button-text">适应视图</span>
</button>
<button
type="button"
className="kg-icon-button"
title="放大"
onClick={props.onZoomIn}
>
<ZoomIn size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="缩小"
onClick={props.onZoomOut}
>
<ZoomOut size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="导出图片"
onClick={props.onExport}
>
<Download size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="全屏"
onClick={props.onFullscreen}
>
<Maximize2 size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="刷新图谱数据"
onClick={props.onRefresh}
disabled={props.refreshing}
>
{props.refreshing ? (
<Loader2 size={15} className="animate-spin" />
) : (
<RefreshCw size={15} />
)}
</button>
<div className="kg-header-divider" />
<button
type="button"
className={`kg-icon-button${props.showSidebar ? " kg-icon-button-active" : ""}`}
title="筛选面板"
onClick={props.onToggleSidebar}
>
<PanelLeft size={15} />
</button>
<button
type="button"
className={`kg-icon-button${props.showDetail ? " kg-icon-button-active" : ""}`}
title="详情面板"
onClick={props.onToggleDetail}
>
<PanelRight size={15} />
</button>
</div>
</header>
);
}
@@ -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<NodeKind, number>;
edgeLabelCounts: Map<string, number>;
disabledNodeKinds: Set<NodeKind>;
disabledEdgeLabels: Set<string>;
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 (
<aside className="kg-panel kg-sidebar">
<div className="kg-panel-header">
<span className="kg-panel-title">搜索与筛选</span>
{props.hiddenCount > 0 ? (
<button
type="button"
className="kg-text-button"
onClick={props.onRestoreHidden}
>
恢复 {props.hiddenCount} 个隐藏节点
</button>
) : null}
</div>
<div className="kg-panel-content">
<div className="kg-search-wrap">
<Search size={14} />
<input
className="kg-search"
placeholder="搜索节点标题..."
value={props.searchKeyword}
onChange={(e) => props.onSearchChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") props.onSearchEnter();
}}
/>
</div>
{props.searchMatchCount !== null ? (
<div
className={`kg-search-hint${props.searchMatchCount === 0 ? " kg-search-hint-warn" : ""}`}
>
{props.searchMatchCount === 0
? "没有匹配的节点,换个关键词试试"
: `匹配 ${props.searchMatchCount} 个节点,回车定位`}
</div>
) : null}
<section className="kg-filter-section">
<div className="kg-filter-title">节点类型</div>
{kinds.map((kind) => (
<label key={kind} className="kg-check-row">
<span className="kg-check-row-main">
<input
type="checkbox"
checked={!props.disabledNodeKinds.has(kind)}
onChange={() => props.onToggleNodeKind(kind)}
/>
<span
className="kg-legend-dot"
style={{ background: NODE_KIND_META[kind].color }}
/>
<span className="kg-check-label">{NODE_KIND_META[kind].label}</span>
</span>
<span className="kg-count">{props.nodeKindCounts.get(kind)}</span>
</label>
))}
</section>
{edgeLabels.length > 0 ? (
<section className="kg-filter-section">
<div className="kg-filter-title">关系类型</div>
{edgeLabels.map(([label, count]) => {
const info = getEdgeInfo(label);
return (
<label key={label} className="kg-check-row">
<span className="kg-check-row-main">
<input
type="checkbox"
checked={!props.disabledEdgeLabels.has(label)}
onChange={() => props.onToggleEdgeLabel(label)}
/>
<span
className="kg-legend-line"
style={{
borderTopColor: info.color,
borderTopStyle: info.dashed ? "dashed" : "solid",
}}
/>
<span className="kg-check-label">{info.zh}</span>
</span>
<span className="kg-count">{count}</span>
</label>
);
})}
</section>
) : null}
<section className="kg-filter-section">
<div className="kg-filter-title">图谱统计</div>
<div className="kg-stats-grid">
<div className="kg-stat-card">
<div className="kg-stat-card-value">{props.stats.nodes}</div>
<div className="kg-stat-card-label">节点</div>
</div>
<div className="kg-stat-card">
<div className="kg-stat-card-value">{props.stats.edges}</div>
<div className="kg-stat-card-label">关系</div>
</div>
<div className="kg-stat-card">
<div className="kg-stat-card-value">
{props.stats.avgDegree.toFixed(2)}
</div>
<div className="kg-stat-card-label">平均度</div>
</div>
<div className="kg-stat-card">
<div className="kg-stat-card-value">{props.stats.largestComponent}</div>
<div className="kg-stat-card-label">最大连通分量</div>
</div>
</div>
</section>
{props.topNodes.length > 0 ? (
<section className="kg-filter-section">
<div className="kg-filter-title">核心节点</div>
{props.topNodes.map(({ node, degree }, i) => (
<div
key={node.id}
className="kg-rank-item"
onClick={() => props.onPickNode(node.id)}
>
<span className="kg-rank-index">{i + 1}</span>
<span className="kg-rank-title">{getNodeTitle(node)}</span>
<span className="kg-rank-degree">{degree} 度</span>
</div>
))}
</section>
) : null}
</div>
</aside>
);
}
@@ -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 (
<div className="kg-tooltip" style={{ left, top }}>
<div className="kg-tooltip-title">{getNodeTitle(tooltip.node)}</div>
<div className="kg-tooltip-desc">
<span className="kg-legend-item">
<span className="kg-legend-dot" style={{ background: meta.color }} />
{meta.label}
</span>
{tooltip.node.category ? <span>· {tooltip.node.category}</span> : null}
<span>· 度数 {tooltip.degree}</span>
</div>
<div className="kg-tooltip-hint">单击查看详情 · 双击聚焦 · 拖拽固定位置</div>
</div>
);
}
@@ -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;
}
}
@@ -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;
}
@@ -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<NodeKind, { label: string; color: string }> = {
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<string, EdgeInfo> = {
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<string, number> {
const degrees = new Map<string, number>();
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<string, string[]>();
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<string>();
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;
}
}
@@ -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 (
<div className="flex h-11 shrink-0 items-center gap-1.5 overflow-hidden border-b border-slate-50 bg-white px-5 text-[13px] text-slate-400 sm:px-8 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-500">
{segments.map((segment, i) => (
<Fragment key={`${segment}-${i}`}>
<span className="shrink-0 whitespace-nowrap">{segment}</span>
<ChevronRight className="size-3.5 shrink-0 text-slate-300 dark:text-slate-600" />
</Fragment>
))}
<span className="truncate font-semibold text-slate-700 dark:text-slate-200">
{current}
</span>
</div>
);
}
@@ -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 (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 text-[13px] text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800",
danger &&
"border-red-200 bg-red-50 text-red-600 hover:bg-red-100 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-400 dark:hover:bg-red-500/20",
)}
>
{icon}
{label}
</button>
);
}
/** 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 (
<div className="flex min-h-12 shrink-0 flex-wrap items-center gap-2 border-b border-slate-50 bg-white px-5 py-1.5 sm:px-8 dark:border-slate-800 dark:bg-slate-900">
<span className="inline-flex h-6 items-center rounded-md bg-red-50 px-2.5 text-xs font-bold text-red-600 dark:bg-red-500/10 dark:text-red-400">
{editing ? "MARKDOWN EDIT" : "MARKDOWN VIEW"}
</span>
<span className="inline-flex h-6 items-center rounded-md bg-slate-100 px-2 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
{statusLabel}
</span>
<span className="text-xs text-slate-400">{charCount.toLocaleString()} 字符</span>
<span className="text-xs text-slate-400">更新时间:{updatedAt}</span>
<div className="ml-auto flex items-center gap-1.5">
{editing ? (
<>
<ToolbarButton
icon={<X className="size-3.5" />}
label="取消"
onClick={onCancelEdit}
/>
<ToolbarButton
icon={<Save className="size-3.5" />}
label={saving ? "保存中..." : "保存"}
onClick={onSave}
disabled={saving}
/>
</>
) : (
<>
<ToolbarButton
icon={<Pencil className="size-3.5" />}
label="编辑"
onClick={onEdit}
/>
<ToolbarButton
icon={<Download className="size-3.5" />}
label="导出"
onClick={onExport}
/>
<ToolbarButton
icon={<Trash2 className="size-3.5" />}
label="归档"
onClick={onArchive}
disabled={archiving}
danger
/>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,18 @@
/** Shown in the reading column when no document is selected. */
export function EmptyDocumentState() {
return (
<div className="flex h-full items-center justify-center bg-white px-6 text-center dark:bg-slate-900">
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-10 py-12 sm:px-12 dark:border-slate-700 dark:bg-slate-800/50">
<div className="mb-4 text-[40px] leading-none">📚</div>
<div className="mb-2 text-lg font-bold text-slate-700 dark:text-slate-200">
选择一篇文档开始阅读
</div>
<div className="text-sm leading-7 text-slate-400">
你可以从左侧目录树中选择知识文档,
<br />
也可以导入 Markdown、JSON、TXT 或文件夹来构建 LLM Wiki。
</div>
</div>
</div>
);
}
@@ -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<string>;
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 (
<aside
className={cn(
"flex min-h-0 flex-col border-r border-slate-100 bg-white dark:border-slate-800 dark:bg-slate-900",
props.className,
)}
>
<div className="border-b border-slate-50 p-4 dark:border-slate-800">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input
value={props.keyword}
onChange={(e) => props.onKeywordChange(e.target.value)}
placeholder="快速查找内容或标题..."
className="h-9 w-full rounded-[10px] border border-slate-200 bg-slate-50 pl-9 pr-3 text-sm text-slate-700 outline-none transition-colors placeholder:text-slate-400 focus:border-red-600 focus:bg-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:bg-slate-900"
/>
</div>
<button
type="button"
onClick={() => setFiltersOpen((v) => !v)}
className={cn(
"relative inline-flex size-9 shrink-0 items-center justify-center rounded-[10px] border transition-colors",
filtersOpen || activeFilters > 0
? "border-red-200 bg-red-50 text-red-600 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-400"
: "border-slate-200 bg-white text-slate-500 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800",
)}
aria-label="高级筛选"
title="高级筛选"
>
<SlidersHorizontal className="size-4" />
{activeFilters > 0 ? (
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white">
{activeFilters}
</span>
) : null}
</button>
</div>
{filtersOpen ? (
<div className="mt-3 flex flex-col gap-2">
<Select value={props.sourceType} onValueChange={props.onSourceTypeChange}>
<SelectTrigger className="h-9 w-full">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部来源</SelectItem>
{Object.entries(SOURCE_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={props.status} onValueChange={props.onStatusChange}>
<SelectTrigger className="h-9 w-full">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
</div>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2.5 py-3">
<div className="mx-2 my-2 text-xs font-bold uppercase tracking-wide text-slate-400 dark:text-slate-500">
文档目录结构
</div>
{props.isLoading ? (
<div className="flex items-center gap-2 p-4 text-sm text-slate-400">
<Loader2 className="size-4 animate-spin" /> 加载中...
</div>
) : props.tree.length === 0 ? (
<div className="mx-2 rounded-lg border border-dashed border-slate-200 p-5 text-center text-sm text-slate-400 dark:border-slate-700">
{searching || activeFilters > 0
? "没有匹配的文档"
: "暂无知识。点击「新建知识」或在对话页「保存到知识库」。"}
</div>
) : (
<KnowledgeTree
nodes={props.tree}
selectedId={props.selectedId}
expandedIds={props.expandedIds}
forceExpand={searching}
onToggleFolder={props.onToggleFolder}
onSelect={props.onSelect}
onExportNote={props.onExportNote}
/>
)}
</div>
<div className="flex h-[42px] shrink-0 items-center justify-center border-t border-slate-50 text-xs text-slate-400 dark:border-slate-800 dark:text-slate-500">
本地加载 {props.total} 篇文档
</div>
</aside>
);
}
@@ -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<string>;
/** 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 (
<div>
<div
role="button"
tabIndex={0}
onClick={() => 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"
>
<ChevronRight
className={cn(
"size-3.5 shrink-0 text-slate-400 transition-transform",
open && "rotate-90",
)}
/>
{open ? (
<FolderOpen className="size-4 shrink-0 text-amber-500" />
) : (
<Folder className="size-4 shrink-0 text-amber-500" />
)}
<span className="flex-1 truncate">{node.name}</span>
<span className="text-xs font-normal text-slate-300 opacity-0 group-hover:opacity-100 dark:text-slate-600">
{node.children?.length ?? 0}
</span>
</div>
{open && node.children ? (
<div>
{node.children.map((child) => (
<TreeRow key={child.id} {...props} node={child} depth={depth + 1} />
))}
</div>
) : null}
</div>
);
}
const active = node.id === selectedId;
return (
<div
role="button"
tabIndex={0}
onClick={() => 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",
)}
>
<FileText
className={cn(
"size-4 shrink-0",
active ? "text-red-500" : "text-slate-400 dark:text-slate-500",
)}
/>
<span className="flex-1 truncate" title={node.name}>
{node.name}
</span>
{node.item ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="inline-flex size-6 shrink-0 items-center justify-center rounded text-slate-400 opacity-0 hover:bg-slate-200/60 group-hover:opacity-100 data-[state=open]:opacity-100 dark:hover:bg-slate-700"
aria-label="更多操作"
>
<MoreHorizontal className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={() => props.onSelect(node.item!)}>
打开文档
</DropdownMenuItem>
<DropdownMenuItem onClick={() => props.onExportNote(node.item!)}>
导出 Markdown
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
);
}
/** Recursive folder/document tree for the wiki sidebar. */
export function KnowledgeTree(props: KnowledgeTreeProps) {
return (
<div className="flex flex-col gap-px">
{props.nodes.map((node) => (
<TreeRow key={node.id} {...props} node={node} depth={0} />
))}
</div>
);
}
@@ -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<HTMLDivElement>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const headings = root.querySelectorAll<HTMLElement>("h1, h2, h3, h4");
headings.forEach((el, index) => {
const item = toc[index];
if (item) el.id = item.id;
});
}, [content, toc]);
return (
<div ref={rootRef} className="wiki-markdown">
<MarkdownRenderer
content={content}
wikiLinks={wikiLinks}
onWikiLinkClick={onWikiLinkClick}
/>
</div>
);
}
@@ -0,0 +1,65 @@
import { ListTree } from "lucide-react";
import { cn } from "@/lib/utils";
import type { TocItem } from "./wikiTypes";
const LEVEL_PADDING: Record<number, string> = {
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 (
<aside
className={cn(
"min-h-0 overflow-y-auto border-l border-slate-100 bg-white px-5 py-6 dark:border-slate-800 dark:bg-slate-900",
className,
)}
>
<div className="mb-5 flex items-center gap-2 text-[13px] font-bold text-slate-500 dark:text-slate-400">
<ListTree className="size-4" />
本文目录大纲
</div>
{items.length === 0 ? (
<div className="text-[13px] leading-7 text-slate-400">
暂无大纲。
<br />
选择文档后,这里会展示从 Markdown 标题解析出的文章结构。
</div>
) : (
<nav className="flex flex-col">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onItemClick(item.id)}
title={item.text}
className={cn(
"cursor-pointer truncate border-l-2 border-transparent py-[5px] pr-1 text-left text-[13px] leading-relaxed text-slate-500 transition-colors hover:text-red-600 dark:text-slate-400 dark:hover:text-red-400",
LEVEL_PADDING[item.level] ?? "pl-9",
activeId === item.id &&
"border-red-600 font-semibold text-red-600 dark:text-red-400",
)}
>
{item.text}
</button>
))}
</nav>
)}
</aside>
);
}
@@ -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<string, { label: string; className: string }> = {
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 (
<span className="inline-flex h-6 items-center rounded-md bg-slate-100 px-2 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
{children}
</span>
);
}
/** 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<HTMLDivElement>(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<string>(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<HTMLElement>(
".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 (
<div className="flex min-h-0 min-w-0 flex-col bg-white dark:bg-slate-900">
<Breadcrumb segments={noteBreadcrumbSegments(note)} current={note.title} />
<DocumentToolbar
charCount={note.content_md.length}
updatedAt={formatDateTime(note.updated_at)}
statusLabel={STATUS_LABELS[note.status] ?? note.status}
editing={editing}
saving={update.isPending}
archiving={remove.isPending}
onEdit={() => setEditing(true)}
onCancelEdit={() => setEditing(false)}
onSave={() => void handleSave()}
onExport={() => downloadMarkdown(note)}
onArchive={() => void handleArchive()}
/>
<div
ref={scrollRef}
onScroll={editing ? undefined : handleScroll}
className="min-h-0 flex-1 overflow-y-auto"
>
<div className="mx-auto w-full max-w-[880px] px-5 pb-32 pt-10 sm:px-10 lg:px-14">
{editing ? (
<div className="flex flex-col gap-3">
<Input
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
placeholder="标题"
/>
<div className="flex items-center gap-2">
<Input
value={draftTags}
onChange={(e) => setDraftTags(e.target.value)}
placeholder="标签,逗号分隔"
/>
<Select value={draftStatus} onValueChange={setDraftStatus}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
</div>
<Textarea
rows={24}
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
className="font-mono text-sm"
/>
</div>
) : (
<>
<h1 className="mb-3 text-[32px] font-extrabold leading-[1.35] text-slate-900 dark:text-slate-100">
{note.title}
</h1>
{note.summary ? (
<div className="my-4 rounded-lg border-l-4 border-slate-200 bg-slate-50 px-4 py-3.5 text-sm leading-7 text-slate-600 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300">
{note.summary}
</div>
) : null}
<div className="mb-8 mt-4 flex flex-wrap items-center gap-2">
<MetaTag>
来源:{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
</MetaTag>
<MetaTag>状态:{STATUS_LABELS[note.status] ?? note.status}</MetaTag>
{note.created_at ? (
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
) : null}
{note.tags.map((tag) => (
<span
key={tag}
className="inline-flex h-6 items-center rounded-md bg-red-50 px-2 text-xs font-medium text-red-600 dark:bg-red-500/10 dark:text-red-400"
>
#{tag}
</span>
))}
</div>
{note.entities && note.entities.length > 0 ? (
<div className="mb-8 flex flex-wrap items-center gap-1.5">
<span className="mr-0.5 inline-flex items-center gap-1 text-xs text-slate-400">
<Sparkles className="size-3" /> 实体
</span>
{note.entities.map((ent) => {
const meta = entityMeta(ent.entity_type);
return (
<span
key={`${ent.name}-${ent.entity_type ?? ""}`}
title={meta.label}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium",
meta.className,
)}
>
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-70" />
<span>{ent.name}</span>
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
{meta.label}
</span>
</span>
);
})}
</div>
) : null}
<MarkdownViewer
content={note.content_md}
toc={toc}
wikiLinks={wikiLinks}
onWikiLinkClick={onWikiLinkClick}
/>
{note.sources && note.sources.length > 0 ? (
<div className="mt-10 border-t border-slate-100 pt-5 dark:border-slate-800">
<h2 className="mb-3 text-sm font-bold text-slate-700 dark:text-slate-300">
来源
</h2>
<ul className="flex flex-col gap-2">
{note.sources.map((src) => (
<li key={src.id} className="text-sm">
{src.url ? (
<a
href={src.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-blue-600 hover:underline dark:text-blue-400"
>
{src.title || src.url}
<ExternalLink className="size-3" />
</a>
) : src.thread_id ? (
<button
type="button"
className="text-blue-600 hover:underline dark:text-blue-400"
onClick={() =>
navigate(`/page/workspace/chats/${src.thread_id}`)
}
>
{src.title || `对话 ${src.thread_id.slice(0, 8)}`}
</button>
) : (
<span className="text-slate-600 dark:text-slate-300">
{src.title || src.tool_name || "来源"}
</span>
)}
{src.snippet ? (
<p className="mt-0.5 line-clamp-2 text-xs text-slate-400">
{src.snippet}
</p>
) : null}
</li>
))}
</ul>
</div>
) : null}
<div className="mt-10 border-t border-slate-100 pt-3 text-xs text-slate-400 dark:border-slate-800">
创建人 {actorLabel(note.created_by_name, note.created_by)} · 更新人{" "}
{actorLabel(note.updated_by_name, note.updated_by)} · 更新于{" "}
{formatDateTime(note.updated_at)}
</div>
</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,112 @@
import {
BookOpen,
Copy,
Menu,
Network,
Plus,
RefreshCw,
Upload,
} from "lucide-react";
import { cn } from "@/lib/utils";
function HeaderButton({
icon,
label,
onClick,
variant = "outline",
className,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
variant?: "outline" | "primary";
className?: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg border px-3 text-sm transition-colors",
variant === "primary"
? "border-red-600 bg-red-600 text-white hover:bg-red-700"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800",
className,
)}
>
{icon}
<span className="hidden sm:inline">{label}</span>
</button>
);
}
/** Light 56px wiki header: logo + product name on the left, action entries on the right. */
export function WikiHeader({
onToggleSidebar,
onImportFile,
onBatchImport,
onDedup,
onGraph,
onCreate,
}: {
onToggleSidebar: () => void;
onImportFile: () => void;
onBatchImport: () => void;
onDedup: () => void;
onGraph: () => void;
onCreate: () => void;
}) {
return (
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center gap-3">
<button
type="button"
onClick={onToggleSidebar}
className="inline-flex size-9 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-100 md:hidden dark:text-slate-400 dark:hover:bg-slate-800"
aria-label="打开目录"
>
<Menu className="size-5" />
</button>
<div className="flex size-9 items-center justify-center rounded-[9px] bg-red-600 text-white">
<BookOpen className="size-5" />
</div>
<div className="leading-none">
<div className="text-base font-extrabold tracking-tight text-slate-900 dark:text-slate-100">
知识库
</div>
<div className="mt-1 text-xs text-emerald-500">LLM Wiki · 知识库中心</div>
</div>
</div>
<div className="flex items-center gap-2">
<HeaderButton
icon={<Upload className="size-4" />}
label="导入文件"
onClick={onImportFile}
/>
<HeaderButton
icon={<RefreshCw className="size-4" />}
label="批量导入"
onClick={onBatchImport}
/>
<HeaderButton
icon={<Copy className="size-4" />}
label="查重"
onClick={onDedup}
/>
<HeaderButton
icon={<Network className="size-4" />}
label="图谱"
onClick={onGraph}
/>
<HeaderButton
icon={<Plus className="size-4" />}
label="新建知识"
onClick={onCreate}
variant="primary"
/>
</div>
</header>
);
}
+145
View File
@@ -0,0 +1,145 @@
/* Wiki reading-area typography: overrides the generic MarkdownRenderer classes
inside .wiki-markdown to get a document-site reading experience. */
.wiki-markdown h1 {
font-size: 32px;
line-height: 1.35;
font-weight: 800;
color: #111827;
margin: 0 0 28px;
}
.wiki-markdown h2 {
font-size: 22px;
font-weight: 700;
line-height: 1.4;
margin: 42px 0 16px;
padding-left: 12px;
border-left: 4px solid #dc2626;
color: #111827;
}
.wiki-markdown h3 {
font-size: 18px;
font-weight: 700;
margin: 28px 0 12px;
color: #1f2937;
}
.wiki-markdown h4 {
font-size: 16px;
font-weight: 700;
margin: 24px 0 10px;
color: #334155;
}
.wiki-markdown p {
font-size: 15.5px;
line-height: 1.9;
color: #334155;
margin-bottom: 16px;
}
.wiki-markdown ul,
.wiki-markdown ol {
padding-left: 24px;
margin-bottom: 16px;
}
.wiki-markdown li {
margin: 8px 0;
line-height: 1.8;
color: #334155;
font-size: 15.5px;
}
.wiki-markdown strong {
color: #111827;
font-weight: 700;
}
.wiki-markdown blockquote {
border-left: 4px solid #e5e7eb;
background: #f9fafb;
padding: 12px 18px;
color: #475569;
margin: 18px 0;
border-radius: 0 8px 8px 0;
font-style: normal;
}
.wiki-markdown blockquote p {
margin-bottom: 0;
}
.wiki-markdown table {
width: 100%;
border-collapse: collapse;
margin: 24px 0;
font-size: 14px;
}
.wiki-markdown th,
.wiki-markdown td {
border: 1px solid #e5e7eb;
padding: 10px 12px;
}
.wiki-markdown th {
background: #f8fafc;
font-weight: 700;
color: #334155;
}
.wiki-markdown hr {
margin: 28px 0;
border-color: #edf0f5;
}
/* Anchor target offset so headings are not hidden behind the sticky chrome. */
.wiki-markdown h1,
.wiki-markdown h2,
.wiki-markdown h3,
.wiki-markdown h4 {
scroll-margin-top: 16px;
}
/* Dark theme adjustments (app toggles the .dark class on a root element). */
.dark .wiki-markdown h1,
.dark .wiki-markdown h2 {
color: #f1f5f9;
}
.dark .wiki-markdown h3,
.dark .wiki-markdown h4 {
color: #e2e8f0;
}
.dark .wiki-markdown p,
.dark .wiki-markdown li {
color: #cbd5e1;
}
.dark .wiki-markdown strong {
color: #f8fafc;
}
.dark .wiki-markdown blockquote {
background: #1e293b;
border-left-color: #475569;
color: #cbd5e1;
}
.dark .wiki-markdown th {
background: #1e293b;
color: #e2e8f0;
}
.dark .wiki-markdown th,
.dark .wiki-markdown td {
border-color: #334155;
}
.dark .wiki-markdown hr {
border-color: #334155;
}
@@ -0,0 +1,17 @@
import type { KnowledgeNote } from "@/core/knowledge/types";
/** One entry of the in-document outline, parsed from markdown headings (h1–h4). */
export interface TocItem {
id: string;
text: string;
level: number;
}
/** A node of the left-hand knowledge directory tree. */
export interface TreeNode {
id: string;
name: string;
type: "folder" | "file";
children?: TreeNode[];
item?: KnowledgeNote;
}
@@ -0,0 +1,169 @@
import type { KnowledgeNote } from "@/core/knowledge/types";
import type { TocItem, TreeNode } from "./wikiTypes";
export const SOURCE_TYPE_LABELS: Record<string, string> = {
thread: "对话沉淀",
search: "检索沉淀",
tool: "工具沉淀",
file: "文件导入",
manual: "手动创建",
report: "研究报告",
};
export const STATUS_LABELS: Record<string, string> = {
draft: "草稿",
approved: "已批准",
archived: "已归档",
};
const FALLBACK_FOLDER = "未分类";
/** Stable slug for a heading: keeps CJK characters, collapses whitespace to dashes. */
export function slugify(text: string): string {
return text
.trim()
.toLowerCase()
.replace(/[#`*_~[\]()]/g, "")
.replace(/\s+/g, "-")
.replace(/[^\w一-龥-]/g, "");
}
/**
* Parse h1–h4 headings out of raw markdown (skipping fenced code blocks) and
* assign each a stable id; duplicate titles get a numeric suffix. The same id
* sequence is mounted onto the rendered headings by MarkdownViewer.
*/
export function extractToc(markdown: string): TocItem[] {
const usedIds = new Map<string, number>();
const items: TocItem[] = [];
let inFence = false;
for (const line of markdown.split("\n")) {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
continue;
}
if (inFence) continue;
const match = /^(#{1,4})\s+(.+)$/.exec(line);
if (!match) continue;
const level = match[1].length;
const text = match[2].replace(/[#`*_~]/g, "").trim();
const baseId = slugify(text) || "heading";
const count = usedIds.get(baseId) ?? 0;
usedIds.set(baseId, count + 1);
items.push({ id: count === 0 ? baseId : `${baseId}-${count + 1}`, text, level });
}
return items;
}
/** Folder path segments for a note: vault_path directories, else its source-type group. */
export function noteFolderSegments(note: KnowledgeNote): string[] {
const rawPath = note.vault_path?.replace(/\\/g, "/").replace(/^\/+/, "");
if (rawPath && rawPath.includes("/")) {
return rawPath.split("/").slice(0, -1).filter(Boolean);
}
return [SOURCE_TYPE_LABELS[note.source_type] ?? FALLBACK_FOLDER];
}
/** Breadcrumb segments for a note (folders only; the title is appended by the caller). */
export function noteBreadcrumbSegments(note: KnowledgeNote): string[] {
return ["知识库", ...noteFolderSegments(note)];
}
function sortTree(nodes: TreeNode[]): void {
nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
return a.name.localeCompare(b.name, "zh-CN");
});
for (const node of nodes) {
if (node.children) sortTree(node.children);
}
}
/**
* Build the directory tree from the flat note list. Notes with a vault_path
* become nested folders mirroring that path; others are grouped by source type.
*/
export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] {
const root: TreeNode[] = [];
const folderMap = new Map<string, TreeNode>();
const ensureFolder = (segments: string[]): TreeNode[] => {
let children = root;
let path = "";
for (const segment of segments) {
path = path ? `${path}/${segment}` : segment;
let node = folderMap.get(path);
if (!node) {
node = { id: `folder:${path}`, name: segment, type: "folder", children: [] };
folderMap.set(path, node);
children.push(node);
}
children = node.children!;
}
return children;
};
for (const note of notes) {
ensureFolder(noteFolderSegments(note)).push({
id: note.id,
name: note.title || "未命名文档",
type: "file",
item: note,
});
}
sortTree(root);
return root;
}
/** Ids of every folder in the tree (used by "expand all" while searching). */
export function collectFolderIds(nodes: TreeNode[]): string[] {
const ids: string[] = [];
const walk = (list: TreeNode[]) => {
for (const node of list) {
if (node.type === "folder") {
ids.push(node.id);
if (node.children) walk(node.children);
}
}
};
walk(nodes);
return ids;
}
/** Folder ids on the path from the root down to the given note, or null if absent. */
export function findAncestorFolderIds(
nodes: TreeNode[],
noteId: string,
): string[] | null {
for (const node of nodes) {
if (node.type === "file" && node.id === noteId) return [];
if (node.type === "folder" && node.children) {
const sub = findAncestorFolderIds(node.children, noteId);
if (sub) return [node.id, ...sub];
}
}
return null;
}
export function formatDateTime(value?: string | null): string {
return value ? value.slice(0, 19).replace("T", " ") : "—";
}
export function formatDate(value?: string | null): string {
return value ? value.slice(0, 10) : "—";
}
/** Trigger a client-side download of the note body as a .md file. */
export function downloadMarkdown(note: KnowledgeNote): void {
const blob = new Blob([note.content_md], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${(note.title || "knowledge").replace(/[\\/:*?"<>|]/g, "_")}.md`;
anchor.click();
URL.revokeObjectURL(url);
}
@@ -57,6 +57,7 @@ import {
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { usePublishSquares } from "@/core/system-settings/hooks";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
@@ -226,6 +227,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
const [editPublished, setEditPublished] = useState(agent.published);
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
const [editSquareId, setEditSquareId] = useState(agent.square_id ?? "");
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const { data: publishSquares } = usePublishSquares();
const [selectedSkills, setSelectedSkills] = useState<string[]>(
agent.skills ?? [],
@@ -390,6 +392,12 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
toast.warning("该智能体已加入私密广场,不可发布");
return;
}
// Publishing: prompt for which square to publish into (when any squares are
// configured). Unpublishing: just flip it off.
if (!agent.published && (publishSquares?.squares.length ?? 0) > 0) {
setPublishPickerOpen(true);
return;
}
try {
await updateAgent.mutateAsync({
id: agent.id,
@@ -401,6 +409,21 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
}
}
async function handlePublishToSquare(squareId: string) {
try {
await updateAgent.mutateAsync({
id: agent.id,
request: { published: true, square_id: squareId },
});
const sqName =
publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
toast.success(`已发布到「${sqName}」`);
setPublishPickerOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePrivate() {
const turningOn = !agent.is_private;
try {
@@ -1083,6 +1106,16 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
if (!o) setDetailSkillName(null);
}}
/>
<PublishSquarePickerDialog
open={publishPickerOpen}
onOpenChange={setPublishPickerOpen}
squares={publishSquares?.squares ?? []}
defaultSquareId={agent.square_id || publishSquares?.default_square_id}
loading={updateAgent.isPending}
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
/>
</>
);
}
@@ -30,24 +30,21 @@ export function AgentGallery() {
const { t } = useI18n();
const router = useRouter();
const [viewMode, setViewMode] = useViewMode("agent");
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
"mine",
);
const [activeTab, setActiveTab] = useState<string>("mine");
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<AgentSort>("time_desc");
// Publish-square sub-filter for the 广场 tab. "" / undefined = all squares.
const [squareFilter, setSquareFilter] = useState<string>("");
const { data: publishSquares } = usePublishSquares();
// Each configured publish square (发布广场) becomes its own browse tab; the
// backend always returns at least one (the built-in default).
const squareTabs = publishSquares?.squares ?? [];
// Publisher filter: click a card's 发布者 to keep only that user's agents.
// {id} is the owner user_id (server filter), {label} the desensitized name.
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
null,
);
// Per-tab 1-based page index, preserved while switching tabs.
const [pages, setPages] = useState<{ all: number; mine: number; private: number }>(
{ all: 1, mine: 1, private: 1 },
);
// Per-tab 1-based page index (keyed by tab value), preserved across switches.
const [pages, setPages] = useState<Record<string, number>>({});
// Search + tag filtering are both server-side now. The backend mirrors the
// frontend's desensitize map, so typing the displayed abbreviation (e.g.
@@ -55,8 +52,21 @@ export function AgentGallery() {
// filters change, every tab returns to its first page.
const tagKey = tagIds.join(",");
useEffect(() => {
setPages({ all: 1, mine: 1, private: 1 });
}, [search, tagKey, sort, publisher?.id, squareFilter]);
setPages({});
}, [search, tagKey, sort, publisher?.id]);
// If the active square tab disappears (admin removed that square), fall back
// to "我的" so the gallery never points at a dead tab.
useEffect(() => {
if (
activeTab !== "mine" &&
activeTab !== "private" &&
squareTabs.length > 0 &&
!squareTabs.some((sq) => sq.id === activeTab)
) {
setActiveTab("mine");
}
}, [activeTab, squareTabs]);
const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled;
@@ -71,8 +81,9 @@ export function AgentGallery() {
// Only the active tab fetches. "广场" maps to the server "square" scope
// (built-in/published, never private), "我的" to "mine" (owned + favorited).
const isSquareTab = activeTab !== "mine" && activeTab !== "private";
const mainScope = activeTab === "mine" ? "mine" : "square";
const mainPage = activeTab === "mine" ? pages.mine : pages.all;
const mainPage = pages[activeTab] ?? 1;
const {
agents: mainAgents,
total: mainTotal,
@@ -87,8 +98,8 @@ export function AgentGallery() {
pageSize: PAGE_SIZE,
enabled: activeTab !== "private",
owner: publisher?.id,
// Square sub-filter only applies to the public 广场 tab.
square: activeTab === "all" && squareFilter ? squareFilter : undefined,
// Each square tab fetches only that square's agents.
square: isSquareTab ? activeTab : undefined,
});
const {
@@ -124,7 +135,7 @@ export function AgentGallery() {
const showSearchBar = activeTab !== "private" || privateUnlocked;
const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
const setTabPage = (tab: "all" | "mine" | "private", page: number) =>
const setTabPage = (tab: string, page: number) =>
setPages((prev) => ({ ...prev, [tab]: page }));
const handleNewAgent = () => {
@@ -179,7 +190,7 @@ export function AgentGallery() {
list: Agent[];
total: number;
isLoading: boolean;
tab: "all" | "mine" | "private";
tab: string;
emptyDescription: string;
showNewButton?: boolean;
}) => {
@@ -200,7 +211,7 @@ export function AgentGallery() {
<div className="flex flex-col gap-4">
{renderGrid(list)}
<Pagination
page={pages[tab]}
page={pages[tab] ?? 1}
pageSize={PAGE_SIZE}
total={total}
onPageChange={(p) => setTabPage(tab, p)}
@@ -329,7 +340,7 @@ export function AgentGallery() {
<Tabs
value={activeTab}
onValueChange={(v) => {
setActiveTab(v as "all" | "mine" | "private");
setActiveTab(v);
// Each tab starts from a clean slate: switching tabs clears the
// keyword / tag / sort / publisher filters instead of carrying them
// over. The TagSearchBar's own input box is reset via its key below.
@@ -342,9 +353,13 @@ export function AgentGallery() {
>
<div className="mx-6 mt-4 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<TabsList className="w-auto self-start">
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
<TabsTrigger value="mine">我的</TabsTrigger>
<TabsTrigger value="all">广场</TabsTrigger>
{squareTabs.map((sq) => (
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
{sq.name}
</TabsTrigger>
))}
{privateFeatureAvailable && (
<TabsTrigger value="private">
<LockIcon className="mr-1 h-3.5 w-3.5" />
@@ -383,49 +398,23 @@ export function AgentGallery() {
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<>
{activeTab === "all" &&
(publishSquares?.squares.length ?? 0) > 0 && (
<Select
value={squareFilter || "__all__"}
onValueChange={(v) =>
setSquareFilter(v === "__all__" ? "" : v)
}
>
<SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[140px]"
title="发布广场"
>
<SelectValue placeholder="全部广场" />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="__all__">全部广场</SelectItem>
{publishSquares?.squares.map((sq) => (
<SelectItem key={sq.id} value={sq.id}>
{sq.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
<SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
title="排序"
>
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="time_desc">最新创建</SelectItem>
<SelectItem value="time_asc">最早创建</SelectItem>
<SelectItem value="updated_desc">最近更新</SelectItem>
<SelectItem value="updated_asc">最早更新</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
</>
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
<SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
title="排序"
>
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="time_desc">最新创建</SelectItem>
<SelectItem value="time_asc">最早创建</SelectItem>
<SelectItem value="updated_desc">最近更新</SelectItem>
<SelectItem value="updated_asc">最早更新</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
}
/>
)}
@@ -445,15 +434,21 @@ export function AgentGallery() {
)}
</div>
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
tab: "all",
emptyDescription: t.agents.emptyDescription,
})}
</TabsContent>
{squareTabs.map((sq) => (
<TabsContent
key={sq.id}
value={sq.id}
className="flex-1 overflow-y-auto p-6 mt-0"
>
{renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
tab: sq.id,
emptyDescription: `「${sq.name}」广场暂时还没有智能体。`,
})}
</TabsContent>
))}
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({
@@ -0,0 +1,114 @@
"use client";
import { CheckIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { PublishSquare } from "@/core/system-settings/api";
import { cn } from "@/lib/utils";
interface PublishSquarePickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
squares: PublishSquare[];
/** Pre-selected square id (current square, or the configured default). */
defaultSquareId?: string;
/** Called with the chosen square id when the user confirms. */
onConfirm: (squareId: string) => void;
loading?: boolean;
title?: string;
description?: string;
}
/** A "publish to which square (发布广场)?" chooser. Squares are rendered as a
* selectable list (not a dropdown) so the choice is explicit. */
export function PublishSquarePickerDialog({
open,
onOpenChange,
squares,
defaultSquareId,
onConfirm,
loading,
title = "选择发布广场",
description = "请选择要把它发布到哪个广场。每个项目只能归属一个广场,不会重复出现。",
}: PublishSquarePickerDialogProps) {
const [selected, setSelected] = useState<string>(
defaultSquareId || squares[0]?.id || "",
);
// Re-seed the selection whenever the dialog (re)opens or the default changes.
useEffect(() => {
if (open) {
setSelected(defaultSquareId || squares[0]?.id || "");
}
}, [open, defaultSquareId, squares]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="max-h-[50vh] space-y-1.5 overflow-y-auto py-1">
{squares.map((sq) => {
const active = selected === sq.id;
return (
<button
key={sq.id}
type="button"
onClick={() => setSelected(sq.id)}
className={cn(
"flex w-full items-start gap-2 rounded-md border px-3 py-2 text-left transition-colors",
active
? "border-primary bg-primary/5 dark:border-[#3B82F6] dark:bg-[#0a3568]"
: "hover:border-primary/40 hover:bg-accent/40 dark:border-[#213D5E]",
)}
>
<span
className={cn(
"mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border",
active
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/40",
)}
>
{active && <CheckIcon className="size-3" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{sq.name}
</span>
{sq.description?.trim() && (
<span className="text-muted-foreground block truncate text-xs">
{sq.description}
</span>
)}
</span>
</button>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
取消
</Button>
<Button
onClick={() => onConfirm(selected)}
disabled={loading || !selected}
>
{loading ? "发布中…" : "确认发布"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -58,6 +58,7 @@ import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
@@ -164,9 +165,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
null,
);
const [viewMode, setViewMode] = useViewMode("skill");
// Publish-square sub-filter for the 广场 tab. "" = all squares.
const [squareFilter, setSquareFilter] = useState<string>("");
const { data: publishSquares } = usePublishSquares();
// Each configured publish square (发布广场) becomes its own browse tab; the
// backend always returns at least one (the built-in default).
const squareTabs = publishSquares?.squares ?? [];
const reconcileMutation = useReconcileCustomSkills();
const fillNamesMutation = useGenerateMissingSkillNameZh();
// Optional model override for the admin "一键补充中文名" action (null = default).
@@ -217,22 +219,25 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
};
}, [skills, currentUserId]);
// "mine" tab shows my own skills; every other tab value is a square id whose
// public skills come from the published pool.
const isSquareTab = filter !== "mine";
const scopedSkills = filter === "mine" ? activeMine : activeAll;
const filteredSkills = useMemo(() => {
let list = publisher
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
: scopedSkills;
// Square sub-filter only applies to the public 广场 tab. The default square
// also sweeps in legacy/unassigned published skills (square_id === "").
if (filter === "all" && squareFilter) {
const isDefault = squareFilter === publishSquares?.default_square_id;
// On a square tab, keep only that square's skills. The default square also
// sweeps in legacy/unassigned published skills (square_id === "").
if (isSquareTab) {
const isDefault = filter === publishSquares?.default_square_id;
list = list.filter((s) => {
const sid = s.square_id ?? "";
return sid === squareFilter || (isDefault && !sid);
return sid === filter || (isDefault && !sid);
});
}
return list;
}, [scopedSkills, publisher, filter, squareFilter, publishSquares?.default_square_id]);
}, [scopedSkills, publisher, filter, isSquareTab, publishSquares?.default_square_id]);
const orderedSkills = useMemo(
() =>
filteredSkills
@@ -258,6 +263,18 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
useEffect(() => {
setPage(1);
}, [search, tagIds, sort, publisher?.id, filter]);
// If the active square tab disappears (admin removed that square), fall back
// to "我的" so the page never points at a dead tab.
useEffect(() => {
if (
filter !== "mine" &&
squareTabs.length > 0 &&
!squareTabs.some((sq) => sq.id === filter)
) {
setFilter("mine");
}
}, [filter, squareTabs]);
const pagedSkills = useMemo(
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[orderedSkills, page],
@@ -315,9 +332,13 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<header className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<Tabs value={filter} onValueChange={setFilter}>
<TabsList variant="line">
<TabsList variant="line" className="flex-wrap">
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
{squareTabs.map((sq) => (
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
{sq.name}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Button
@@ -424,30 +445,6 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
onTagIdsChange={setTagIds}
actions={
<div className="flex items-center gap-2">
{filter === "all" && (publishSquares?.squares.length ?? 0) > 0 && (
<Select
value={squareFilter || "__all__"}
onValueChange={(v) => {
setSquareFilter(v === "__all__" ? "" : v);
setPage(1);
}}
>
<SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[140px]"
title="发布广场"
>
<SelectValue placeholder="全部广场" />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="__all__">全部广场</SelectItem>
{publishSquares?.squares.map((sq) => (
<SelectItem key={sq.id} value={sq.id}>
{sq.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<ViewModeToggle value={viewMode} onChange={setViewMode} />
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
<SelectTrigger
@@ -589,6 +586,7 @@ export function SkillRow({
const [detailOpen, setDetailOpen] = useState(false);
const [duplicateOpen, setDuplicateOpen] = useState(false);
const [duplicateName, setDuplicateName] = useState("");
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const isCustom = skill.category === "custom";
// Built-in flag falls back to "no DB owner" so legacy responses without
@@ -602,6 +600,12 @@ export function SkillRow({
const canTogglePublish = canManage && !isBuiltin;
async function handleTogglePublished(next: boolean) {
// Publishing: prompt for which square to publish into (when configured).
// Unpublishing: just flip it off.
if (next && (cardPublishSquares?.squares.length ?? 0) > 0) {
setPublishPickerOpen(true);
return;
}
try {
await setPublished.mutateAsync({ skillName: skill.name, published: next });
toast.success(
@@ -614,17 +618,19 @@ export function SkillRow({
}
}
// Move an already-published skill into a different square (发布广场). Each
// skill belongs to exactly one square; re-publishing with a new square_id
// relocates it rather than duplicating it.
async function handleChangeSquare(squareId: string) {
// Publish the skill into a chosen square. Each skill belongs to exactly one
// square; re-publishing with a new square_id relocates rather than duplicates.
async function handlePublishToSquare(squareId: string) {
try {
await setPublished.mutateAsync({
skillName: skill.name,
published: true,
squareId,
});
toast.success("已更新发布广场");
const sqName =
cardPublishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
toast.success(`已发布到「${sqName}」`);
setPublishPickerOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
@@ -897,28 +903,6 @@ export function SkillRow({
)}
</Button>
)}
{canTogglePublish &&
skill.published &&
(cardPublishSquares?.squares.length ?? 0) > 0 && (
<Select
value={skill.square_id || cardPublishSquares?.default_square_id || ""}
onValueChange={(v) => void handleChangeSquare(v)}
>
<SelectTrigger
className="h-8 w-28 shrink-0 gap-1 px-2 text-xs"
title="发布广场"
>
<SelectValue placeholder="发布广场" />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
{cardPublishSquares?.squares.map((sq) => (
<SelectItem key={sq.id} value={sq.id}>
{sq.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button
size="icon"
variant="ghost"
@@ -1053,6 +1037,16 @@ export function SkillRow({
open={detailOpen}
onOpenChange={setDetailOpen}
/>
<PublishSquarePickerDialog
open={publishPickerOpen}
onOpenChange={setPublishPickerOpen}
squares={cardPublishSquares?.squares ?? []}
defaultSquareId={skill.square_id || cardPublishSquares?.default_square_id}
loading={setPublished.isPending}
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
description="请选择要把该技能发布到哪个广场。每个技能只能归属一个广场,不会重复出现。"
/>
</>
);
}
+9
View File
@@ -10,6 +10,7 @@ import {
deleteKnowledgeNote,
exportKnowledgeGraph,
fetchVaultPage,
getKnowledgeGraph,
getKnowledgeNote,
ingestKnowledgeFile,
ingestThread,
@@ -71,6 +72,14 @@ export function useKnowledgeSearch(query: string, enabled: boolean) {
});
}
export function useKnowledgeGraph() {
return useQuery({
queryKey: [...ROOT_KEY, "graph"],
queryFn: getKnowledgeGraph,
refetchOnWindowFocus: false,
});
}
export function useKnowledgeActions() {
const queryClient = useQueryClient();
const invalidate = () =>
+167 -478
View File
@@ -1,24 +1,8 @@
import {
BookOpen,
Copy,
ExternalLink,
Loader2,
Network,
Pencil,
Plus,
RefreshCw,
Save,
Search,
Sparkles,
Trash2,
Upload,
X,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -30,14 +14,18 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState";
import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar";
import { TocPanel } from "@/components/wiki/TocPanel";
import { WikiContent } from "@/components/wiki/WikiContent";
import { WikiHeader } from "@/components/wiki/WikiHeader";
import {
buildKnowledgeTree,
downloadMarkdown,
extractToc,
findAncestorFolderIds,
} from "@/components/wiki/wikiUtils";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import {
useKnowledgeActions,
@@ -53,47 +41,6 @@ import MarkdownRenderer, {
type WikiLinkTarget,
} from "@/strategy-components/MarkdownRenderer";
const SOURCE_TYPE_LABELS: Record<string, string> = {
thread: "对话",
search: "检索",
tool: "工具",
file: "文件",
manual: "手动",
report: "报告",
};
const STATUS_LABELS: Record<string, string> = {
draft: "草稿",
approved: "已批准",
archived: "已归档",
};
/** 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 "—";
}
const ENTITY_TYPE_META: Record<string, { label: string; className: string }> = {
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",
}
);
}
function useDebounced<T>(value: T, delay = 350): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
@@ -103,54 +50,6 @@ function useDebounced<T>(value: T, delay = 350): T {
return debounced;
}
function NoteListItem({
note,
active,
onClick,
}: {
note: KnowledgeNote;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full flex-col gap-1 rounded-md border p-3 text-left transition-colors",
active
? "border-primary bg-primary/5"
: "border-border/70 hover:bg-muted/50",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="line-clamp-1 text-sm font-medium">{note.title}</span>
<Badge variant="outline" className="shrink-0 text-[10px]">
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
</Badge>
</div>
{note.summary ? (
<span className="text-muted-foreground line-clamp-2 text-xs">
{note.summary}
</span>
) : null}
<div className="mt-1 flex flex-wrap items-center gap-1">
{note.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]"
>
#{tag}
</span>
))}
<span className="text-muted-foreground ml-auto text-[10px]">
{note.updated_at?.slice(0, 10)}
</span>
</div>
</button>
);
}
function CreateNoteDialog({
open,
onOpenChange,
@@ -550,13 +449,93 @@ function VaultPageDialog({
);
}
function NoteDetail({ noteId }: { noteId: string }) {
export default function KnowledgeBasePage() {
const navigate = useNavigate();
const { data: note, isLoading } = useKnowledgeNote(noteId);
const { data: wikiLinks } = useNoteWikilinks(noteId);
const { update, remove } = useKnowledgeActions();
const { noteId } = useParams<{ noteId: string }>();
// --- list / tree state ---
const [keyword, setKeyword] = useState("");
const debouncedKeyword = useDebounced(keyword);
const [sourceType, setSourceType] = useState("all");
const [status, setStatus] = useState("all");
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const treeInitializedRef = useRef(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
// --- dialogs ---
const [createOpen, setCreateOpen] = useState(false);
const [batchOpen, setBatchOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [dedupOpen, setDedupOpen] = useState(false);
const [vaultPath, setVaultPath] = useState<string | null>(null);
// --- reading state ---
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
const params = useMemo(
() => ({
keyword: debouncedKeyword || undefined,
source_type: sourceType === "all" ? undefined : sourceType,
status: status === "all" ? undefined : status,
limit: 500,
}),
[debouncedKeyword, sourceType, status],
);
const { data, isLoading } = useKnowledgeNotes(params);
const notes = data?.items ?? [];
const tree = useMemo(() => buildKnowledgeTree(notes), [notes]);
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
const { data: wikiLinks } = useNoteWikilinks(noteId);
const tocItems = useMemo(
() => (note ? extractToc(note.content_md) : []),
[note],
);
// First load: expand the root-level folders so the tree reads as a directory.
useEffect(() => {
if (treeInitializedRef.current || tree.length === 0) return;
treeInitializedRef.current = true;
setExpandedIds(
new Set(tree.filter((n) => n.type === "folder").map((n) => n.id)),
);
}, [tree]);
// Keep the selected document's ancestor folders open (deep links included).
useEffect(() => {
if (!noteId || tree.length === 0) return;
const ancestors = findAncestorFolderIds(tree, noteId);
if (!ancestors || ancestors.length === 0) return;
setExpandedIds((prev) => {
if (ancestors.every((id) => prev.has(id))) return prev;
const next = new Set(prev);
for (const id of ancestors) next.add(id);
return next;
});
}, [noteId, tree]);
const handleToggleFolder = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSelectNote = (item: KnowledgeNote) => {
setMobileSidebarOpen(false);
navigate(`/page/workspace/knowledge/${item.id}`);
};
const handleTocClick = (id: string) => {
setActiveHeadingId(id);
document
.getElementById(id)
?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const handleWikiLink = (link: WikiLinkTarget) => {
if (link.kind === "note" && link.note_id) {
navigate(`/page/workspace/knowledge/${link.note_id}`);
@@ -564,235 +543,81 @@ function NoteDetail({ noteId }: { noteId: string }) {
setVaultPath(link.vault_path);
}
};
const [editing, setEditing] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const [draftTags, setDraftTags] = useState("");
const [draftContent, setDraftContent] = useState("");
const [draftStatus, setDraftStatus] = useState("approved");
useEffect(() => {
setEditing(false);
setVaultPath(null);
}, [noteId]);
useEffect(() => {
if (note) {
setDraftTitle(note.title);
setDraftTags(note.tags.join(", "));
setDraftContent(note.content_md);
setDraftStatus(note.status);
}
}, [note]);
if (isLoading || !note) {
return (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
{isLoading ? "加载中..." : "知识不存在"}
</div>
);
}
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 (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-2 border-b p-3">
<div className="flex items-center gap-2">
<Badge variant="outline">
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
</Badge>
<Badge variant="secondary">{STATUS_LABELS[note.status] ?? note.status}</Badge>
</div>
<div className="flex items-center gap-2">
{editing ? (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
<X className="size-4" /> 取消
</Button>
<Button size="sm" onClick={() => void handleSave()} disabled={update.isPending}>
<Save className="size-4" /> 保存
</Button>
</>
) : (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
<Pencil className="size-4" /> 编辑
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => void handleArchive()}
disabled={remove.isPending}
>
<Trash2 className="size-4" /> 归档
</Button>
</>
<div className="flex size-full min-h-0 flex-col bg-slate-50 dark:bg-slate-950">
<WikiHeader
onToggleSidebar={() => setMobileSidebarOpen((v) => !v)}
onImportFile={() => setImportOpen(true)}
onBatchImport={() => setBatchOpen(true)}
onDedup={() => setDedupOpen(true)}
onGraph={() => navigate("/page/workspace/knowledge/graph")}
onCreate={() => setCreateOpen(true)}
/>
<div className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[300px_minmax(0,1fr)] xl:grid-cols-[300px_minmax(0,1fr)_280px]">
{mobileSidebarOpen ? (
<div
className="fixed inset-0 z-40 bg-black/30 md:hidden"
onClick={() => setMobileSidebarOpen(false)}
aria-hidden
/>
) : null}
<KnowledgeSidebar
keyword={keyword}
onKeywordChange={setKeyword}
sourceType={sourceType}
onSourceTypeChange={setSourceType}
status={status}
onStatusChange={setStatus}
tree={tree}
expandedIds={expandedIds}
onToggleFolder={handleToggleFolder}
selectedId={noteId}
onSelect={handleSelectNote}
onExportNote={downloadMarkdown}
total={data?.total ?? 0}
isLoading={isLoading}
className={cn(
"max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:z-50 max-md:w-[300px] max-md:shadow-xl max-md:transition-transform max-md:duration-200",
mobileSidebarOpen ? "max-md:translate-x-0" : "max-md:-translate-x-full",
)}
</div>
/>
{!noteId ? (
<EmptyDocumentState />
) : noteLoading || !note ? (
<div className="flex h-full items-center justify-center bg-white text-sm text-slate-400 dark:bg-slate-900">
{noteLoading ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="size-4 animate-spin" /> 加载中...
</span>
) : (
"知识不存在"
)}
</div>
) : (
<WikiContent
note={note}
toc={tocItems}
wikiLinks={wikiLinks}
onWikiLinkClick={handleWikiLink}
onActiveHeadingChange={setActiveHeadingId}
/>
)}
<TocPanel
items={tocItems}
activeId={activeHeadingId}
onItemClick={handleTocClick}
className="hidden xl:block"
/>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-4">
{editing ? (
<div className="flex flex-col gap-3">
<Input
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
placeholder="标题"
/>
<div className="flex items-center gap-2">
<Input
value={draftTags}
onChange={(e) => setDraftTags(e.target.value)}
placeholder="标签,逗号分隔"
/>
<Select value={draftStatus} onValueChange={setDraftStatus}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
</div>
<Textarea
rows={20}
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
className="font-mono text-sm"
/>
</div>
) : (
<>
<h1 className="mb-3 text-xl font-semibold">{note.title}</h1>
{note.tags.length > 0 ? (
<div className="mb-2 flex flex-wrap items-center gap-1.5">
{note.tags.map((tag) => (
<span
key={tag}
className="border-primary/20 bg-primary/5 text-primary/90 hover:bg-primary/10 inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors"
>
#{tag}
</span>
))}
</div>
) : null}
{note.entities && note.entities.length > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-1.5">
<span className="text-muted-foreground mr-0.5 inline-flex items-center gap-1 text-xs">
<Sparkles className="size-3" /> 实体
</span>
{note.entities.map((ent) => {
const meta = entityMeta(ent.entity_type);
return (
<span
key={`${ent.name}-${ent.entity_type ?? ""}`}
title={meta.label}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium",
meta.className,
)}
>
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-70" />
<span>{ent.name}</span>
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
{meta.label}
</span>
</span>
);
})}
</div>
) : null}
<MarkdownRenderer
content={note.content_md}
wikiLinks={wikiLinks}
onWikiLinkClick={handleWikiLink}
/>
{note.sources && note.sources.length > 0 ? (
<div className="mt-6 border-t pt-4">
<h2 className="mb-2 text-sm font-semibold">来源</h2>
<ul className="flex flex-col gap-2">
{note.sources.map((src) => (
<li key={src.id} className="text-sm">
{src.url ? (
<a
href={src.url}
target="_blank"
rel="noreferrer"
className="text-primary inline-flex items-center gap-1 hover:underline"
>
{src.title || src.url}
<ExternalLink className="size-3" />
</a>
) : src.thread_id ? (
<button
type="button"
className="text-primary hover:underline"
onClick={() =>
navigate(`/page/workspace/chats/${src.thread_id}`)
}
>
{src.title || `对话 ${src.thread_id.slice(0, 8)}`}
</button>
) : (
<span>{src.title || src.tool_name || "来源"}</span>
)}
{src.snippet ? (
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs">
{src.snippet}
</p>
) : null}
</li>
))}
</ul>
</div>
) : null}
<div className="text-muted-foreground mt-6 border-t pt-3 text-xs">
创建人 {actorLabel(note.created_by_name, note.created_by)} · 更新人{" "}
{actorLabel(note.updated_by_name, note.updated_by)} · 更新于{" "}
{note.updated_at?.slice(0, 19).replace("T", " ")}
</div>
</>
)}
</div>
</ScrollArea>
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
<ImportFileDialog open={importOpen} onOpenChange={setImportOpen} />
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
<VaultPageDialog
path={vaultPath}
onOpenChange={(open) => {
@@ -802,139 +627,3 @@ function NoteDetail({ noteId }: { noteId: string }) {
</div>
);
}
export default function KnowledgeBasePage() {
const navigate = useNavigate();
const { noteId } = useParams<{ noteId: string }>();
const [keyword, setKeyword] = useState("");
const debouncedKeyword = useDebounced(keyword);
const [sourceType, setSourceType] = useState("all");
const [status, setStatus] = useState("all");
const [createOpen, setCreateOpen] = useState(false);
const [batchOpen, setBatchOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [dedupOpen, setDedupOpen] = useState(false);
const params = useMemo(
() => ({
keyword: debouncedKeyword || undefined,
source_type: sourceType === "all" ? undefined : sourceType,
status: status === "all" ? undefined : status,
limit: 50,
}),
[debouncedKeyword, sourceType, status],
);
const { data, isLoading } = useKnowledgeNotes(params);
const notes = data?.items ?? [];
return (
<div className="flex size-full min-h-0 flex-col">
<header className="flex flex-wrap items-center gap-2 border-b p-3">
<BookOpen className="size-5" />
<h1 className="mr-2 text-base font-semibold">知识库</h1>
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 size-4 -translate-y-1/2" />
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索标题 / 摘要 / 正文 / 标签"
className="w-64 pl-8"
/>
</div>
<Select value={sourceType} onValueChange={setSourceType}>
<SelectTrigger className="w-28">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部来源</SelectItem>
{Object.entries(SOURCE_TYPE_LABELS).map(([v, label]) => (
<SelectItem key={v} value={v}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-28">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<Upload className="size-4" /> 导入文件
</Button>
<Button variant="outline" size="sm" onClick={() => setBatchOpen(true)}>
<RefreshCw className="size-4" /> 批量导入
</Button>
<Button variant="outline" size="sm" onClick={() => setDedupOpen(true)}>
<Copy className="size-4" /> 查重
</Button>
<Button
variant="outline"
size="sm"
onClick={() => navigate("/page/workspace/knowledge/graph")}
>
<Network className="size-4" /> 图谱
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" /> 新建
</Button>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[360px_minmax(0,1fr)]">
<div className="flex min-h-0 flex-col border-r">
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-2 p-3">
{isLoading ? (
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中...
</div>
) : notes.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
暂无知识。点击「新建」或在对话页「保存到知识库」。
</div>
) : (
notes.map((note) => (
<NoteListItem
key={note.id}
note={note}
active={note.id === noteId}
onClick={() => navigate(`/page/workspace/knowledge/${note.id}`)}
/>
))
)}
</div>
</ScrollArea>
{data ? (
<div className="text-muted-foreground border-t p-2 text-center text-xs">
共 {data.total} 条知识
</div>
) : null}
</div>
<div className="min-h-0">
{noteId ? (
<NoteDetail noteId={noteId} />
) : (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
从左侧选择一条知识查看详情
</div>
)}
</div>
</div>
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
<ImportFileDialog open={importOpen} onOpenChange={setImportOpen} />
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
</div>
);
}
+393 -70
View File
@@ -1,87 +1,410 @@
import { ArrowLeft, Loader2, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Loader2, Network, Tags } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { useTheme } from "@/contexts/ThemeContext";
import { knowledgeExportFileUrl } from "@/core/knowledge/api";
import { useKnowledgeActions } from "@/core/knowledge/hooks";
import {
GraphCanvas,
type GraphCanvasHandle,
} from "@/components/knowledge-graph/GraphCanvas";
import { GraphDetailPanel } from "@/components/knowledge-graph/GraphDetailPanel";
import { GraphFooter } from "@/components/knowledge-graph/GraphFooter";
import { GraphHeader } from "@/components/knowledge-graph/GraphHeader";
import { GraphSidebar } from "@/components/knowledge-graph/GraphSidebar";
import { GraphTooltip } from "@/components/knowledge-graph/GraphTooltip";
import type {
LayoutType,
NodeKind,
TooltipState,
} from "@/components/knowledge-graph/graphTypes";
import {
computeDegrees,
computeGraphStats,
getEdgeLabel,
getNodeKind,
getNodeTitle,
} from "@/components/knowledge-graph/graphUtils";
import "@/components/knowledge-graph/graphStyles.css";
import { useKnowledgeGraph, useKnowledgeNote } from "@/core/knowledge/hooks";
export default function KnowledgeGraphPage() {
const navigate = useNavigate();
const { theme } = useTheme();
const { exportGraph } = useKnowledgeActions();
// Keep the served base URL (without the theme) so a theme toggle can re-skin
// the iframe without regenerating the graph on the backend.
const [baseSrc, setBaseSrc] = useState<string | null>(null);
const [stats, setStats] = useState<{ nodes: number; edges: number } | null>(null);
const pageRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<GraphCanvasHandle>(null);
const regenerate = useCallback(async () => {
try {
const result = await exportGraph.mutateAsync();
setStats(result.stats);
// Cache-bust the iframe so it reflects the freshly generated graph.
setBaseSrc(`${knowledgeExportFileUrl("graph.html")}?t=${Date.now()}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成图谱失败");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
const { data, isLoading, isError, error, refetch, isFetching } =
useKnowledgeGraph();
// ------------------------------------------------------------------ state
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [searchKeyword, setSearchKeyword] = useState("");
const [disabledNodeKinds, setDisabledNodeKinds] = useState<Set<NodeKind>>(
new Set(),
);
const [disabledEdgeLabels, setDisabledEdgeLabels] = useState<Set<string>>(
new Set(),
);
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<string>>(new Set());
const [layoutType, setLayoutType] = useState<LayoutType>("force");
const [showLabels, setShowLabels] = useState(true);
const [showSidebar, setShowSidebar] = useState(true);
const [showDetail, setShowDetail] = useState(true);
const [zoomLevel, setZoomLevel] = useState(1);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
// Auto-collapse the side panels on narrow viewports.
useEffect(() => {
const detailQuery = window.matchMedia("(max-width: 1280px)");
const sidebarQuery = window.matchMedia("(max-width: 900px)");
const apply = () => {
setShowDetail(!detailQuery.matches);
setShowSidebar(!sidebarQuery.matches);
};
apply();
detailQuery.addEventListener("change", apply);
sidebarQuery.addEventListener("change", apply);
return () => {
detailQuery.removeEventListener("change", apply);
sidebarQuery.removeEventListener("change", apply);
};
}, []);
useEffect(() => {
void regenerate();
}, [regenerate]);
// ---------------------------------------------------------------- derived
const allNodes = useMemo(() => data?.nodes ?? [], [data]);
const allEdges = useMemo(() => data?.edges ?? [], [data]);
// The graph HTML lives in an iframe and can't read the app's CSS theme, so we
// pass the active day/night mode via a query param; toggling re-renders it.
const src = baseSrc ? `${baseSrc}&theme=${theme}` : null;
const nodeById = useMemo(
() => new Map(allNodes.map((n) => [n.id, n])),
[allNodes],
);
const degrees = useMemo(
() => computeDegrees(allNodes, allEdges),
[allNodes, allEdges],
);
const stats = useMemo(
() => computeGraphStats(allNodes, allEdges),
[allNodes, allEdges],
);
const nodeKindCounts = useMemo(() => {
const counts = new Map<NodeKind, number>();
for (const n of allNodes) {
const kind = getNodeKind(n);
counts.set(kind, (counts.get(kind) ?? 0) + 1);
}
return counts;
}, [allNodes]);
const edgeLabelCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const e of allEdges) {
const label = getEdgeLabel(e);
counts.set(label, (counts.get(label) ?? 0) + 1);
}
return counts;
}, [allEdges]);
const filteredNodes = useMemo(
() =>
allNodes.filter(
(n) => !disabledNodeKinds.has(getNodeKind(n)) && !hiddenNodeIds.has(n.id),
),
[allNodes, disabledNodeKinds, hiddenNodeIds],
);
const filteredEdges = useMemo(() => {
const visible = new Set(filteredNodes.map((n) => n.id));
return allEdges.filter(
(e) =>
visible.has(e.source) &&
visible.has(e.target) &&
!disabledEdgeLabels.has(getEdgeLabel(e)),
);
}, [allEdges, filteredNodes, disabledEdgeLabels]);
const searchMatchIds = useMemo(() => {
const keyword = searchKeyword.trim().toLowerCase();
if (!keyword) return null;
const matches = new Set<string>();
for (const n of filteredNodes) {
if (getNodeTitle(n).toLowerCase().includes(keyword)) matches.add(n.id);
}
return matches;
}, [searchKeyword, filteredNodes]);
const topNodes = useMemo(
() =>
[...allNodes]
.map((node) => ({ node, degree: degrees.get(node.id) ?? 0 }))
.sort((a, b) => b.degree - a.degree)
.slice(0, 8),
[allNodes, degrees],
);
const selectedNode = selectedNodeId ? (nodeById.get(selectedNodeId) ?? null) : null;
const selectedNeighbors = useMemo(() => {
if (!selectedNodeId) return [];
const items: Array<{ node: (typeof allNodes)[number]; relation: string }> = [];
const seen = new Set<string>();
for (const e of filteredEdges) {
const otherId =
e.source === selectedNodeId
? e.target
: e.target === selectedNodeId
? e.source
: null;
if (!otherId || seen.has(otherId)) continue;
const other = nodeById.get(otherId);
if (!other) continue;
seen.add(otherId);
items.push({ node: other, relation: getEdgeLabel(e) });
}
return items.sort(
(a, b) => (degrees.get(b.node.id) ?? 0) - (degrees.get(a.node.id) ?? 0),
);
}, [selectedNodeId, filteredEdges, nodeById, degrees, allNodes]);
// Note detail for note-type nodes (summary / tags / timestamps / status).
const noteId =
selectedNode && (selectedNode.type || "").toLowerCase() === "note"
? selectedNode.id
: undefined;
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
// --------------------------------------------------------------- handlers
const handleSelectNode = useCallback((id: string | null) => {
setSelectedNodeId(id);
}, []);
const handlePickNode = useCallback((id: string) => {
setSelectedNodeId(id);
canvasRef.current?.focusNode(id);
}, []);
const handleSearchEnter = useCallback(() => {
if (!searchMatchIds || searchMatchIds.size === 0) return;
const first = searchMatchIds.values().next().value;
if (first) handlePickNode(first);
}, [searchMatchIds, handlePickNode]);
const handleToggleNodeKind = useCallback((kind: NodeKind) => {
setDisabledNodeKinds((prev) => {
const next = new Set(prev);
if (next.has(kind)) next.delete(kind);
else next.add(kind);
return next;
});
}, []);
const handleToggleEdgeLabel = useCallback((label: string) => {
setDisabledEdgeLabels((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
}, []);
const handleHideNode = useCallback((id: string) => {
setHiddenNodeIds((prev) => new Set(prev).add(id));
setSelectedNodeId((current) => (current === id ? null : current));
}, []);
const handleRestoreHidden = useCallback(() => {
setHiddenNodeIds(new Set());
}, []);
const handleRefresh = useCallback(() => {
void refetch().then((result) => {
if (result.isError) {
toast.error(
result.error instanceof Error ? result.error.message : "刷新图谱失败",
);
}
});
}, [refetch]);
const handleFullscreen = useCallback(() => {
const el = pageRef.current;
if (!el) return;
if (document.fullscreenElement) void document.exitFullscreen();
else void el.requestFullscreen();
}, []);
const handleViewSource = useCallback(
(id: string) => {
navigate(`/page/workspace/knowledge/${encodeURIComponent(id)}`);
},
[navigate],
);
// ---------------------------------------------------------------- overlay
let overlay: React.ReactNode = null;
if (isLoading) {
overlay = (
<div className="kg-canvas-status">
<Loader2 size={26} className="animate-spin" />
<div className="kg-canvas-status-title">正在加载知识图谱...</div>
</div>
);
} else if (isError) {
overlay = (
<div className="kg-canvas-status">
<Network size={30} />
<div className="kg-canvas-status-title">图谱加载失败</div>
<div className="kg-canvas-status-desc">
{error instanceof Error ? error.message : "请求失败,请稍后重试。"}
</div>
<button type="button" className="kg-icon-button" onClick={handleRefresh}>
重试
</button>
</div>
);
} else if (allNodes.length === 0) {
overlay = (
<div className="kg-canvas-status">
<Network size={30} />
<div className="kg-canvas-status-title">暂无知识图谱数据</div>
<div className="kg-canvas-status-desc">
请先导入知识文档,或从知识库中抽取实体、标签与关系后再查看图谱。
</div>
</div>
);
} else if (filteredNodes.length === 0) {
overlay = (
<div className="kg-canvas-status">
<Network size={30} />
<div className="kg-canvas-status-title">当前筛选没有可显示的节点</div>
<div className="kg-canvas-status-desc">
请在左侧勾选更多节点类型,或恢复已隐藏的节点。
</div>
</div>
);
}
const toolbar = (
<>
<div className="kg-floating-pill">
<span className="kg-pill-label">布局</span>
<button
type="button"
className={`kg-pill-button${layoutType === "force" ? " kg-pill-button-active" : ""}`}
onClick={() => setLayoutType("force")}
>
力导向
</button>
<button
type="button"
className={`kg-pill-button${layoutType === "circular" ? " kg-pill-button-active" : ""}`}
onClick={() => setLayoutType("circular")}
>
环形
</button>
</div>
<div className="kg-floating-pill">
<button
type="button"
className={`kg-pill-button${showLabels ? " kg-pill-button-active" : ""}`}
onClick={() => setShowLabels((v) => !v)}
>
<Tags size={13} /> 标签 {showLabels ? "开" : "关"}
</button>
</div>
</>
);
return (
<div className="flex size-full min-h-0 flex-col">
<header className="flex items-center gap-2 border-b p-3">
<Button
variant="ghost"
size="sm"
onClick={() => navigate("/page/workspace/knowledge")}
>
<ArrowLeft className="size-4" /> 返回
</Button>
<h1 className="text-base font-semibold">知识图谱</h1>
{stats ? (
<span className="text-muted-foreground text-xs">
{stats.nodes} 个节点 · {stats.edges} 条关系
</span>
) : null}
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={() => void regenerate()}
disabled={exportGraph.isPending}
>
{exportGraph.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
重新生成
</Button>
</header>
<div className="min-h-0 flex-1">
{src ? (
<iframe
src={src}
title="知识图谱"
className="size-full border-0"
sandbox="allow-scripts allow-same-origin"
<div className="kg-page" ref={pageRef}>
<GraphHeader
nodeCount={filteredNodes.length}
edgeCount={filteredEdges.length}
totalNodes={allNodes.length}
totalEdges={allEdges.length}
refreshing={isFetching}
showSidebar={showSidebar}
showDetail={showDetail}
onBack={() => navigate("/page/workspace/knowledge")}
onRelayout={() => canvasRef.current?.relayout()}
onFitView={() => canvasRef.current?.fitView()}
onZoomIn={() => canvasRef.current?.zoomIn()}
onZoomOut={() => canvasRef.current?.zoomOut()}
onExport={() => canvasRef.current?.exportImage()}
onFullscreen={handleFullscreen}
onRefresh={handleRefresh}
onToggleSidebar={() => setShowSidebar((v) => !v)}
onToggleDetail={() => setShowDetail((v) => !v)}
/>
<div
className={[
"kg-body",
showSidebar ? "" : "kg-hide-left",
showDetail ? "" : "kg-hide-right",
]
.filter(Boolean)
.join(" ")}
>
{showSidebar ? (
<GraphSidebar
searchKeyword={searchKeyword}
searchMatchCount={searchMatchIds ? searchMatchIds.size : null}
nodeKindCounts={nodeKindCounts}
edgeLabelCounts={edgeLabelCounts}
disabledNodeKinds={disabledNodeKinds}
disabledEdgeLabels={disabledEdgeLabels}
stats={stats}
topNodes={topNodes}
hiddenCount={hiddenNodeIds.size}
onSearchChange={setSearchKeyword}
onSearchEnter={handleSearchEnter}
onToggleNodeKind={handleToggleNodeKind}
onToggleEdgeLabel={handleToggleEdgeLabel}
onRestoreHidden={handleRestoreHidden}
onPickNode={handlePickNode}
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
<Loader2 className="mr-2 size-4 animate-spin" /> 生成图谱中...
</div>
)}
) : null}
<GraphCanvas
ref={canvasRef}
nodes={filteredNodes}
edges={filteredEdges}
degrees={degrees}
selectedNodeId={selectedNodeId}
searchMatchIds={searchMatchIds}
layoutType={layoutType}
showLabels={showLabels}
onSelectNode={handleSelectNode}
onTooltip={setTooltip}
onZoomChange={setZoomLevel}
toolbar={toolbar}
overlay={overlay}
/>
{showDetail ? (
<GraphDetailPanel
node={selectedNode}
degree={selectedNodeId ? (degrees.get(selectedNodeId) ?? 0) : 0}
neighbors={selectedNeighbors}
note={noteId ? note : undefined}
noteLoading={Boolean(noteId) && noteLoading}
onViewSource={handleViewSource}
onLocate={(id) => canvasRef.current?.focusNode(id)}
onHide={handleHideNode}
onPickNeighbor={handlePickNode}
/>
) : null}
</div>
<GraphFooter
nodeKindCounts={nodeKindCounts}
edgeLabels={[...edgeLabelCounts.keys()]}
zoomLevel={zoomLevel}
selectedTitle={selectedNode ? getNodeTitle(selectedNode) : null}
visibleNodes={filteredNodes.length}
visibleEdges={filteredEdges.length}
/>
<GraphTooltip tooltip={tooltip} />
</div>
);
}
@@ -100,6 +100,36 @@ models:
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: Pro/MiniMaxAI/MiniMax-M2.5
display_name: Pro/MiniMaxAI/MiniMax-M2.5
use: langchain_openai:ChatOpenAI
model: Pro/MiniMaxAI/MiniMax-M2.5
api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: inclusionAI/Ling-flash-2.0
display_name: inclusionAI/Ling-flash-2.0
use: langchain_openai:ChatOpenAI
model: inclusionAI/Ling-flash-2.0
api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-ai/DeepSeek-V4-Pro
display_name: deepseek-ai/DeepSeek-V4-Pro
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-V4-Pro
api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
# 识图(图片识别)配置。
# - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
+21 -1
View File
@@ -71,7 +71,27 @@ models:
display_name: deepseek-ai/DeepSeek-V4-Flash
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-V4-Flash
api_key: sk-qwwwvjompnzxqhndctlwmmomvqdgaqqqgxvibfxchsikvgod
api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: Pro/MiniMaxAI/MiniMax-M2.5
display_name: Pro/MiniMaxAI/MiniMax-M2.5
use: langchain_openai:ChatOpenAI
model: Pro/MiniMaxAI/MiniMax-M2.5
api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: inclusionAI/Ling-flash-2.0
display_name: inclusionAI/Ling-flash-2.0
use: langchain_openai:ChatOpenAI
model: inclusionAI/Ling-flash-2.0
api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0