Merge remote-tracking branch 'origin/master' into xuhd
This commit is contained in:
commit
915233c5b6
171 files changed
+17266
-2105
No files matched your search
@@ -0,0 +1,926 @@
|
||||
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,
|
||||
getEdgeId,
|
||||
getEdgeInfo,
|
||||
getEdgeLabel,
|
||||
getLinkDistance,
|
||||
getNodeKind,
|
||||
getNodeRadius,
|
||||
getNodeTitle,
|
||||
NODE_KIND_META,
|
||||
seedPositions,
|
||||
truncateLabel,
|
||||
} from "./graphUtils";
|
||||
|
||||
const MIN_ZOOM = 0.15;
|
||||
const MAX_ZOOM = 4;
|
||||
|
||||
/** Canvas-drawn colors per app theme (CSS variables don't reach the 2D context). */
|
||||
const CANVAS_PALETTE = {
|
||||
dark: {
|
||||
exportBg: "#0b1020",
|
||||
labelHalo: "rgba(2, 6, 23, 0.9)",
|
||||
nodeRim: "rgba(2, 6, 23, 0.55)",
|
||||
rootRim: "#e0f2fe",
|
||||
expandedRim: "#7dd3fc",
|
||||
rootRing: "rgba(125, 211, 252, 0.9)",
|
||||
expandedDot: "#7dd3fc",
|
||||
expandedDotRim: "rgba(2, 6, 23, 0.8)",
|
||||
selectionRing: "#ffffff",
|
||||
hoverRing: "rgba(255, 255, 255, 0.85)",
|
||||
pin: "rgba(255, 255, 255, 0.8)",
|
||||
labelActive: "#ffffff",
|
||||
label: "#e2e8f0",
|
||||
minimapViewport: "rgba(56, 189, 248, 0.8)",
|
||||
},
|
||||
light: {
|
||||
exportBg: "#f8fafc",
|
||||
labelHalo: "rgba(255, 255, 255, 0.92)",
|
||||
nodeRim: "rgba(255, 255, 255, 0.9)",
|
||||
rootRim: "#075985",
|
||||
expandedRim: "#0284c7",
|
||||
rootRing: "rgba(2, 132, 199, 0.85)",
|
||||
expandedDot: "#0284c7",
|
||||
expandedDotRim: "rgba(255, 255, 255, 0.9)",
|
||||
selectionRing: "#0f172a",
|
||||
hoverRing: "rgba(15, 23, 42, 0.7)",
|
||||
pin: "rgba(15, 23, 42, 0.6)",
|
||||
labelActive: "#0f172a",
|
||||
label: "#334155",
|
||||
minimapViewport: "rgba(2, 132, 199, 0.8)",
|
||||
},
|
||||
} as const;
|
||||
|
||||
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;
|
||||
/** App theme; drives the canvas-drawn colors (labels, rims, rings). */
|
||||
theme: "light" | "dark";
|
||||
onSelectNode: (id: string | null) => void;
|
||||
onTooltip: (tooltip: TooltipState | null) => void;
|
||||
onZoomChange: (k: number) => void;
|
||||
/**
|
||||
* Explore-mode rendering context; null/undefined renders the normal full view.
|
||||
* preview = no root picked yet: nodes are shown faded as pickable起点 candidates.
|
||||
*/
|
||||
explore?: {
|
||||
preview: boolean;
|
||||
rootId: string | null;
|
||||
nodeDepthMap: Map<string, number>;
|
||||
expandedNodeIds: Set<string>;
|
||||
} | null;
|
||||
/** Floating pills rendered top-left over the canvas. */
|
||||
toolbar?: ReactNode;
|
||||
/** Small floating hint rendered bottom-center over the canvas. */
|
||||
hint?: 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);
|
||||
// While the initial layout is still settling we keep the whole graph in
|
||||
// view every frame; any pan/zoom/drag from the user takes over.
|
||||
const interactedRef = 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: getEdgeId(e),
|
||||
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;
|
||||
interactedRef.current = false;
|
||||
// Pre-settle a fresh force layout synchronously (time-bounded) so the
|
||||
// first paint is already roughly laid out instead of an exploding
|
||||
// cloud that drifts out of the viewport.
|
||||
if (props.layoutType === "force") {
|
||||
const deadline = performance.now() + 120;
|
||||
let guard = 0;
|
||||
while (sim.alpha > 0.25 && guard < 300 && performance.now() < deadline) {
|
||||
sim.tick();
|
||||
guard += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
interactedRef.current = false;
|
||||
requestDraw();
|
||||
}, [props.layoutType, requestDraw]);
|
||||
|
||||
// Redraw on highlight-affecting prop changes.
|
||||
useEffect(() => {
|
||||
requestDraw();
|
||||
}, [
|
||||
props.selectedNodeId,
|
||||
props.searchMatchIds,
|
||||
props.showLabels,
|
||||
props.explore,
|
||||
props.theme,
|
||||
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) => {
|
||||
interactedRef.current = true;
|
||||
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;
|
||||
interactedRef.current = true;
|
||||
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;
|
||||
interactedRef.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 = CANVAS_PALETTE[propsRef.current.theme].exportBg;
|
||||
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;
|
||||
const explore = p.explore;
|
||||
const palette = CANVAS_PALETTE[p.theme];
|
||||
|
||||
// ---- 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;
|
||||
|
||||
if (explore && !explore.preview) {
|
||||
const sourceDepth = explore.nodeDepthMap.get(e.source.id) ?? 0;
|
||||
const targetDepth = explore.nodeDepthMap.get(e.target.id) ?? 0;
|
||||
const edgeDepth = Math.max(sourceDepth, targetDepth);
|
||||
alpha *= Math.max(0.48, 1 - edgeDepth * 0.12);
|
||||
}
|
||||
|
||||
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 = palette.labelHalo;
|
||||
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;
|
||||
const depth = explore?.nodeDepthMap.get(n.id);
|
||||
const isExploreRoot = explore?.rootId === n.id;
|
||||
const isExpanded = explore?.expandedNodeIds.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;
|
||||
|
||||
let baseRadius = n.radius;
|
||||
let labelByExplore = false;
|
||||
let strokeWidth = 1.4 / k;
|
||||
let strokeColor: string = palette.nodeRim;
|
||||
let fillColor = n.color;
|
||||
if (explore?.preview) {
|
||||
alpha *= 0.42;
|
||||
baseRadius = Math.max(8, n.radius * 0.86);
|
||||
labelByExplore = isActive || isSelected;
|
||||
} else if (explore && depth != null) {
|
||||
baseRadius = depth === 0 ? 34 : Math.max(14, 28 - depth * 4);
|
||||
alpha *= Math.max(0.45, 1 - depth * 0.12);
|
||||
labelByExplore = depth <= 2 || isActive || isSelected;
|
||||
strokeWidth = (depth === 0 ? 4 : isExpanded ? 3 : 1.5) / k;
|
||||
strokeColor =
|
||||
depth === 0
|
||||
? palette.rootRim
|
||||
: isExpanded
|
||||
? palette.expandedRim
|
||||
: palette.nodeRim;
|
||||
if (isExploreRoot) fillColor = "#38bdf8";
|
||||
}
|
||||
|
||||
const r = baseRadius * (n.id === hover?.id ? 1.15 : 1);
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
// Glow for hubs and the active node.
|
||||
if (
|
||||
alpha > 0.5 &&
|
||||
(isActive || isSelected || isExploreRoot || isExpanded || n.degree >= labelThreshold)
|
||||
) {
|
||||
ctx.shadowColor = fillColor;
|
||||
ctx.shadowBlur = isActive || isSelected || isExploreRoot ? 24 : 12;
|
||||
} else {
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Rim.
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.stroke();
|
||||
|
||||
if (isExploreRoot) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, r + 8 / k, 0, Math.PI * 2);
|
||||
ctx.lineWidth = 1.4 / k;
|
||||
ctx.strokeStyle = palette.rootRing;
|
||||
ctx.stroke();
|
||||
} else if (isExpanded) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x + r * 0.58, n.y - r * 0.58, Math.max(4 / k, 3), 0, Math.PI * 2);
|
||||
ctx.fillStyle = palette.expandedDot;
|
||||
ctx.fill();
|
||||
ctx.lineWidth = 1 / k;
|
||||
ctx.strokeStyle = palette.expandedDotRim;
|
||||
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 = palette.selectionRing;
|
||||
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 = palette.hoverRing;
|
||||
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 = palette.pin;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Label.
|
||||
const wantsLabel =
|
||||
isActive ||
|
||||
isSelected ||
|
||||
isNeighbor ||
|
||||
labelByExplore ||
|
||||
(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 = palette.labelHalo;
|
||||
ctx.strokeText(text, n.x, n.y + r + 5 / k);
|
||||
ctx.fillStyle = isActive || isSelected ? palette.labelActive : palette.label;
|
||||
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 = CANVAS_PALETTE[propsRef.current.theme].minimapViewport;
|
||||
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;
|
||||
// Until the user pans/zooms/drags, keep the settling layout fully in
|
||||
// view every frame so nodes never drift out of the viewport.
|
||||
if (!interactedRef.current && sizeRef.current.w) {
|
||||
fitView(false);
|
||||
autoFitRef.current = true;
|
||||
} else if (!autoFitRef.current && sim.alpha < 0.3) {
|
||||
autoFitRef.current = true;
|
||||
fitView(true);
|
||||
}
|
||||
} else if (sim && !autoFitRef.current && sim.nodes.length && sizeRef.current.w) {
|
||||
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;
|
||||
interactedRef.current = true;
|
||||
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();
|
||||
interactedRef.current = true;
|
||||
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}
|
||||
{props.hint}
|
||||
{minimapVisible ? (
|
||||
<canvas ref={minimapRef} className="kg-minimap" width={180} height={120} />
|
||||
) : null}
|
||||
{props.overlay}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,287 @@
|
||||
import {
|
||||
Ban,
|
||||
Compass,
|
||||
Crosshair,
|
||||
EyeOff,
|
||||
FileText,
|
||||
Loader2,
|
||||
MousePointerClick,
|
||||
Waypoints,
|
||||
X,
|
||||
} 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;
|
||||
/** Explore-mode context; null when in full mode. */
|
||||
explore: {
|
||||
/** Depth of this node in the explore graph; null = not yet visible. */
|
||||
depth: number | null;
|
||||
expanded: boolean;
|
||||
isRoot: boolean;
|
||||
} | null;
|
||||
onViewSource: (noteId: string) => void;
|
||||
onLocate: (id: string) => void;
|
||||
onHide: (id: string) => void;
|
||||
onBlacklist: (id: string) => void;
|
||||
onPickNeighbor: (id: string) => void;
|
||||
onExpandNode: (id: string) => void;
|
||||
onSetExploreRoot: (id: string) => void;
|
||||
onClose: () => 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>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-panel-close-btn"
|
||||
title="收起详情面板"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</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 kg-detail-type-neutral">
|
||||
度数 {props.degree}
|
||||
</span>
|
||||
{props.explore && props.explore.depth !== null ? (
|
||||
<>
|
||||
<span className="kg-detail-type kg-detail-type-primary">
|
||||
{props.explore.isRoot
|
||||
? "探索起点"
|
||||
: `第 ${props.explore.depth} 层`}
|
||||
</span>
|
||||
<span
|
||||
className={`kg-detail-type ${
|
||||
props.explore.expanded
|
||||
? "kg-detail-type-success"
|
||||
: "kg-detail-type-warn"
|
||||
}`}
|
||||
>
|
||||
{props.explore.expanded ? "已展开" : "可展开"}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
title="加入黑名单后该节点将不再出现在图谱中,可在左侧「图谱黑名单」中移除"
|
||||
onClick={() => props.onBlacklist(node.id)}
|
||||
>
|
||||
<Ban size={14} /> 加入黑名单
|
||||
</button>
|
||||
{props.explore ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
disabled={props.explore.depth === null || props.explore.expanded}
|
||||
title={
|
||||
props.explore.expanded
|
||||
? "该节点的一跳邻居已全部展开"
|
||||
: "展开该节点的一跳邻居"
|
||||
}
|
||||
onClick={() => props.onExpandNode(node.id)}
|
||||
>
|
||||
<Waypoints size={14} /> 展开一跳邻居
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
title="清空当前探索路径,以此节点为新起点"
|
||||
onClick={() => props.onSetExploreRoot(node.id)}
|
||||
>
|
||||
<Compass size={14} /> 以此为新起点
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</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,124 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
RefreshCw,
|
||||
Scan,
|
||||
Shuffle,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "lucide-react";
|
||||
|
||||
interface GraphHeaderProps {
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
totalNodes: number;
|
||||
totalEdges: number;
|
||||
refreshing: boolean;
|
||||
onBack: () => void;
|
||||
onRelayout: () => void;
|
||||
onFitView: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onExport: () => void;
|
||||
onFullscreen: () => void;
|
||||
onRefresh: () => 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>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { ChevronDown, ChevronRight, Eraser, Search, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { GraphBlacklistItem } from "@/core/knowledge/api";
|
||||
|
||||
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;
|
||||
blacklistItems: GraphBlacklistItem[];
|
||||
blacklistPending: boolean;
|
||||
junkCandidateCount: number;
|
||||
onSearchChange: (keyword: string) => void;
|
||||
onSearchEnter: () => void;
|
||||
onToggleNodeKind: (kind: NodeKind) => void;
|
||||
onToggleEdgeLabel: (label: string) => void;
|
||||
onRestoreHidden: () => void;
|
||||
onRemoveBlacklist: (entryId: string) => void;
|
||||
onCleanJunk: () => void;
|
||||
onPickNode: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type SectionKey =
|
||||
| "nodeTypes"
|
||||
| "edgeTypes"
|
||||
| "graphStats"
|
||||
| "coreNodes"
|
||||
| "blacklist";
|
||||
|
||||
interface FilterSectionProps {
|
||||
title: string;
|
||||
extra?: string;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function FilterSection(props: FilterSectionProps) {
|
||||
return (
|
||||
<section className="kg-filter-section">
|
||||
<button
|
||||
type="button"
|
||||
className="kg-filter-section-header"
|
||||
onClick={props.onToggle}
|
||||
aria-expanded={!props.collapsed}
|
||||
>
|
||||
<span>{props.title}</span>
|
||||
<span className="kg-filter-section-extra">
|
||||
{props.extra}
|
||||
{props.collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
</button>
|
||||
{!props.collapsed ? (
|
||||
<div className="kg-filter-section-body">{props.children}</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function GraphSidebar(props: GraphSidebarProps) {
|
||||
// 节点类型 / 关系类型默认收起,统计与核心节点默认展开。
|
||||
const [collapsedSections, setCollapsedSections] = useState<
|
||||
Record<SectionKey, boolean>
|
||||
>({
|
||||
nodeTypes: true,
|
||||
edgeTypes: true,
|
||||
graphStats: false,
|
||||
coreNodes: false,
|
||||
blacklist: true,
|
||||
});
|
||||
|
||||
const toggleFilterSection = (key: SectionKey) => {
|
||||
setCollapsedSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
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>
|
||||
<div className="kg-panel-header-actions">
|
||||
{props.hiddenCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="kg-text-button"
|
||||
onClick={props.onRestoreHidden}
|
||||
>
|
||||
恢复 {props.hiddenCount} 个隐藏节点
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="kg-panel-close-btn"
|
||||
title="收起筛选面板"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</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}
|
||||
|
||||
<FilterSection
|
||||
title="节点类型"
|
||||
extra={`${kinds.length} 类`}
|
||||
collapsed={collapsedSections.nodeTypes}
|
||||
onToggle={() => toggleFilterSection("nodeTypes")}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
</FilterSection>
|
||||
|
||||
{edgeLabels.length > 0 ? (
|
||||
<FilterSection
|
||||
title="关系类型"
|
||||
extra={`${edgeLabels.length} 类`}
|
||||
collapsed={collapsedSections.edgeTypes}
|
||||
onToggle={() => toggleFilterSection("edgeTypes")}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</FilterSection>
|
||||
) : null}
|
||||
|
||||
<FilterSection
|
||||
title="图谱统计"
|
||||
collapsed={collapsedSections.graphStats}
|
||||
onToggle={() => toggleFilterSection("graphStats")}
|
||||
>
|
||||
<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>
|
||||
</FilterSection>
|
||||
|
||||
{props.topNodes.length > 0 ? (
|
||||
<FilterSection
|
||||
title="核心节点"
|
||||
extra={`${props.topNodes.length} 个`}
|
||||
collapsed={collapsedSections.coreNodes}
|
||||
onToggle={() => toggleFilterSection("coreNodes")}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
</FilterSection>
|
||||
) : null}
|
||||
|
||||
<FilterSection
|
||||
title="图谱黑名单"
|
||||
extra={`${props.blacklistItems.length} 个`}
|
||||
collapsed={collapsedSections.blacklist}
|
||||
onToggle={() => toggleFilterSection("blacklist")}
|
||||
>
|
||||
<div className="kg-search-hint">
|
||||
黑名单中的主题 / 节点不会出现在图谱中。可在节点详情里加入黑名单。
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
disabled={props.blacklistPending || props.junkCandidateCount === 0}
|
||||
title="将单字符、纯数字符号、通用泛词等低价值主题批量加入黑名单"
|
||||
onClick={props.onCleanJunk}
|
||||
>
|
||||
<Eraser size={13} /> 清理低价值主题
|
||||
{props.junkCandidateCount > 0 ? `(${props.junkCandidateCount})` : ""}
|
||||
</button>
|
||||
{props.blacklistItems.length === 0 ? (
|
||||
<div className="kg-search-hint">暂无黑名单条目。</div>
|
||||
) : (
|
||||
props.blacklistItems.map((item) => (
|
||||
<div key={item.id} className="kg-check-row">
|
||||
<span className="kg-check-row-main">
|
||||
<span className="kg-check-label" title={item.label}>
|
||||
{item.label}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-text-button"
|
||||
disabled={props.blacklistPending}
|
||||
title="移出黑名单,重新显示该节点"
|
||||
onClick={() => props.onRemoveBlacklist(item.id)}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</FilterSection>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,111 @@
|
||||
/** 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";
|
||||
|
||||
/** Graph display mode: full graph vs. click-to-expand exploration. */
|
||||
export type GraphMode = "full" | "explore";
|
||||
|
||||
/** One incremental expansion step in explore mode (for undo / rebuild). */
|
||||
export interface ExploreStep {
|
||||
nodeId: string;
|
||||
depth: number;
|
||||
addedNodeIds: string[];
|
||||
addedEdgeIds: string[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** 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,352 @@
|
||||
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";
|
||||
|
||||
/** Stable edge identity shared by explore-mode visibility sets and the canvas. */
|
||||
export const getEdgeId = (edge: GraphEdge): string =>
|
||||
edge.id ?? `${edge.source}->${edge.target}:${getEdgeLabel(edge)}`;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -4,28 +4,38 @@ import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({
|
||||
modal,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
// 无界嵌入时 Radix modal 会给 body 注入异常 margin-right,导致右侧空白。
|
||||
// 默认降为 non-modal;显式传 modal={true} 仍可强制 modal。
|
||||
return (
|
||||
<DialogPrimitive.Root
|
||||
data-slot="dialog"
|
||||
modal={modal ?? !isWujieEnv()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const DialogTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="dialog-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName;
|
||||
const DialogTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="dialog-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName;
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
@@ -33,68 +43,68 @@ function DialogPortal({
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
const DialogClose = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
|
||||
>(({ ...props }, ref) => {
|
||||
return <DialogPrimitive.Close ref={ref} data-slot="dialog-close" {...props} />;
|
||||
});
|
||||
|
||||
DialogClose.displayName = DialogPrimitive.Close.displayName;
|
||||
const DialogClose = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
|
||||
>(({ ...props }, ref) => {
|
||||
return <DialogPrimitive.Close ref={ref} data-slot="dialog-close" {...props} />;
|
||||
});
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
DialogClose.displayName = DialogPrimitive.Close.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Inline, de-emphasized breadcrumb: folder path + current document title. */
|
||||
export function Breadcrumb({
|
||||
segments,
|
||||
current,
|
||||
className,
|
||||
}: {
|
||||
segments: string[];
|
||||
current: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 overflow-hidden text-xs text-slate-400 dark:text-slate-500",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{segments.map((segment, i) => (
|
||||
<Fragment key={`${segment}-${i}`}>
|
||||
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
|
||||
{segment}
|
||||
</span>
|
||||
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
|
||||
</Fragment>
|
||||
))}
|
||||
<span
|
||||
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
|
||||
title={current}
|
||||
>
|
||||
{current}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Download, Pencil, Save, Trash2, X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ToolbarButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
danger,
|
||||
emphasized,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
emphasized?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"inline-flex h-7 cursor-pointer items-center gap-1 rounded-md px-2 text-xs text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200",
|
||||
danger &&
|
||||
"text-red-500 hover:bg-red-50 hover:text-red-600 dark:text-red-400 dark:hover:bg-red-500/10 dark:hover:text-red-300",
|
||||
emphasized &&
|
||||
"border border-slate-200 bg-white font-medium dark:border-slate-700 dark:bg-slate-900",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact inline toolbar: subtle meta (status / chars / updated) + actions. */
|
||||
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 shrink-0 items-center gap-1.5">
|
||||
{editing ? (
|
||||
<span className="inline-flex h-5 items-center rounded bg-amber-50 px-1.5 text-[11px] font-medium text-amber-600 dark:bg-amber-500/10 dark:text-amber-400">
|
||||
编辑中
|
||||
</span>
|
||||
) : (
|
||||
<span className="hidden h-5 items-center rounded bg-slate-100 px-1.5 text-[11px] text-slate-500 sm:inline-flex dark:bg-slate-800 dark:text-slate-400">
|
||||
{statusLabel}
|
||||
</span>
|
||||
)}
|
||||
<span className="hidden whitespace-nowrap text-xs text-slate-400 lg:inline dark:text-slate-500">
|
||||
{charCount.toLocaleString()} 字符 · {updatedAt}
|
||||
</span>
|
||||
|
||||
<div className="ml-1 flex items-center gap-1">
|
||||
{editing ? (
|
||||
<>
|
||||
<ToolbarButton
|
||||
icon={<X className="size-3.5" />}
|
||||
label="取消"
|
||||
onClick={onCancelEdit}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Save className="size-3.5" />}
|
||||
label={saving ? "保存中..." : "保存"}
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
emphasized
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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,90 @@
|
||||
import { ListTree } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { TocItem } from "./wikiTypes";
|
||||
|
||||
/**
|
||||
* Floating in-document outline: a small "目录" pill at the top-right of the
|
||||
* reading area that opens a popover listing h2/h3 headings. Replaces the old
|
||||
* always-visible right column. Hidden entirely when the document has too few
|
||||
* headings to be worth navigating.
|
||||
*/
|
||||
export function FloatingToc({
|
||||
items,
|
||||
activeId,
|
||||
onItemClick,
|
||||
className,
|
||||
}: {
|
||||
items: TocItem[];
|
||||
activeId: string | null;
|
||||
onItemClick: (id: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close when the document (and thus the heading list) changes.
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [items]);
|
||||
|
||||
// Close on click outside so the popover never lingers over the text.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||
}, [open]);
|
||||
|
||||
if (items.length < 2) return null;
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={cn("flex flex-col items-end", className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-xs shadow-sm backdrop-blur transition-colors",
|
||||
open
|
||||
? "border-slate-300 bg-white text-slate-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200"
|
||||
: "border-slate-200 bg-white/90 text-slate-500 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-400 dark:hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
<ListTree className="size-3.5" />
|
||||
目录
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<nav className="mt-2 max-h-[55vh] w-64 overflow-y-auto rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl dark:border-slate-700 dark:bg-slate-900">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
title={item.text}
|
||||
onClick={() => {
|
||||
onItemClick(item.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"block w-full cursor-pointer truncate rounded-md px-2 py-1.5 text-left text-[13px] leading-snug transition-colors",
|
||||
item.level >= 3 && "pl-5",
|
||||
activeId === item.id
|
||||
? "bg-slate-100 font-medium text-red-600 dark:bg-slate-800 dark:text-red-400"
|
||||
: "text-slate-500 hover:bg-slate-50 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800/60 dark:hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
{item.text}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
Loader2,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
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;
|
||||
/** Collapsed to a narrow rail (desktop only; the mobile drawer always expands). */
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Left column: search + filters + directory tree, collapsible to a narrow rail. */
|
||||
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;
|
||||
|
||||
if (props.collapsed) {
|
||||
return (
|
||||
<aside className="hidden min-h-0 flex-col items-center gap-1 border-r border-slate-100 bg-white py-2.5 md:flex dark:border-slate-800 dark:bg-slate-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onToggleCollapse}
|
||||
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300"
|
||||
aria-label="展开目录"
|
||||
title="展开目录"
|
||||
>
|
||||
<PanelLeftOpen className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onToggleCollapse}
|
||||
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300"
|
||||
aria-label="搜索文档"
|
||||
title="搜索文档"
|
||||
>
|
||||
<Search className="size-4" />
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
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-100 p-2.5 dark:border-slate-800">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={props.keyword}
|
||||
onChange={(e) => props.onKeywordChange(e.target.value)}
|
||||
placeholder="搜索标题或内容..."
|
||||
className="h-8 w-full rounded-lg border border-slate-200 bg-slate-50 pl-8 pr-2.5 text-[13px] text-slate-700 outline-none transition-colors placeholder:text-slate-400 focus:border-slate-400 focus:bg-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-slate-500 dark:focus:bg-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFiltersOpen((v) => !v)}
|
||||
className={cn(
|
||||
"relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg transition-colors",
|
||||
filtersOpen || activeFilters > 0
|
||||
? "bg-red-50 text-red-500 dark:bg-red-500/10 dark:text-red-400"
|
||||
: "text-slate-400 hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300",
|
||||
)}
|
||||
aria-label="高级筛选"
|
||||
title="高级筛选"
|
||||
>
|
||||
<SlidersHorizontal className="size-4" />
|
||||
{activeFilters > 0 ? (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex size-3.5 items-center justify-center rounded-full bg-red-500 text-[9px] font-bold text-white">
|
||||
{activeFilters}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{props.onToggleCollapse ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onToggleCollapse}
|
||||
className="inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 max-md:hidden dark:hover:bg-slate-800 dark:hover:text-slate-300"
|
||||
aria-label="收起目录"
|
||||
title="收起目录"
|
||||
>
|
||||
<PanelLeftClose className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{filtersOpen ? (
|
||||
<div className="mt-2 grid grid-cols-2 gap-1.5">
|
||||
<Select value={props.sourceType} onValueChange={props.onSourceTypeChange}>
|
||||
<SelectTrigger className="h-8 w-full text-[13px]">
|
||||
<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-8 w-full text-[13px]">
|
||||
<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 py-2">
|
||||
<div className="px-2 pb-1.5 pt-0.5 text-[11px] font-semibold 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-8 shrink-0 items-center justify-center border-t border-slate-100 text-[11px] text-slate-400 dark:border-slate-800 dark:text-slate-500">
|
||||
共 {props.total} 篇文档
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
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: `${6 + depth * 10}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}
|
||||
title={node.name}
|
||||
className="group flex h-7 cursor-pointer select-none items-center gap-1 rounded-md pr-2 text-[13px] font-medium text-slate-500 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/70"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 text-slate-400 transition-transform",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
{open ? (
|
||||
<FolderOpen className="size-3.5 shrink-0 text-slate-400 dark:text-slate-500" />
|
||||
) : (
|
||||
<Folder className="size-3.5 shrink-0 text-slate-400 dark:text-slate-500" />
|
||||
)}
|
||||
<span className="flex-1 truncate">{node.name}</span>
|
||||
<span className="text-[11px] 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-7 cursor-pointer select-none items-center gap-1.5 rounded-md border-l-2 border-transparent pr-1 text-[13px]",
|
||||
active
|
||||
? "border-red-500 bg-slate-100 font-medium text-slate-900 dark:bg-slate-800 dark:text-slate-100"
|
||||
: "text-slate-500 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/70",
|
||||
)}
|
||||
>
|
||||
<FileText
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
active ? "text-red-500 dark:text-red-400" : "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-5 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-3.5" />
|
||||
</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,120 @@
|
||||
import { FileText, Link2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
WikiLinkMap,
|
||||
WikiLinkTarget,
|
||||
} from "@/strategy-components/MarkdownRenderer";
|
||||
|
||||
import type { RelatedLink } from "./wikiUtils";
|
||||
|
||||
/**
|
||||
* Compact "相关文档" module rendered from the note's `## Related` wikilinks.
|
||||
* Each entry is resolved through the backend wikilink map so the visible
|
||||
* title always matches the actual navigation target; duplicates (same note)
|
||||
* are collapsed and unresolved targets are shown muted and non-clickable.
|
||||
*/
|
||||
export function RelatedDocs({
|
||||
links,
|
||||
wikiLinks,
|
||||
onOpen,
|
||||
}: {
|
||||
links: RelatedLink[];
|
||||
wikiLinks?: WikiLinkMap;
|
||||
onOpen: (link: WikiLinkTarget) => void;
|
||||
}) {
|
||||
if (links.length === 0) return null;
|
||||
|
||||
// Resolving is still in flight — render nothing rather than dead links.
|
||||
const resolving = !wikiLinks;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const entries: { link: RelatedLink; resolved?: WikiLinkTarget }[] = [];
|
||||
for (const link of links) {
|
||||
const resolved = wikiLinks?.[link.target];
|
||||
const key =
|
||||
resolved?.note_id ?? resolved?.vault_path ?? link.target.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
entries.push({ link, resolved });
|
||||
}
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="mt-10 border-t border-slate-100 pt-5 dark:border-slate-800">
|
||||
<h2 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-slate-600 dark:text-slate-300">
|
||||
<Link2 className="size-3.5 text-slate-400" />
|
||||
相关文档
|
||||
<span className="text-xs font-normal text-slate-400">
|
||||
{entries.length}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{entries.map(({ link, resolved }) => {
|
||||
const label = link.alias || resolved?.title || link.target;
|
||||
const clickable = !!resolved?.exists;
|
||||
const body = (
|
||||
<>
|
||||
<FileText
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
clickable
|
||||
? "text-slate-400 transition-colors group-hover:text-red-500"
|
||||
: "text-slate-300 dark:text-slate-600",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[13px] font-medium",
|
||||
clickable
|
||||
? "text-slate-700 dark:text-slate-200"
|
||||
: "text-slate-400 dark:text-slate-500",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{resolved?.vault_path ? (
|
||||
<span className="block truncate text-[11px] text-slate-400 dark:text-slate-500">
|
||||
{resolved.vault_path}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (clickable) {
|
||||
return (
|
||||
<button
|
||||
key={link.target}
|
||||
type="button"
|
||||
title={label}
|
||||
onClick={() => resolved && onOpen(resolved)}
|
||||
className="group flex cursor-pointer items-center gap-2.5 rounded-lg border border-slate-200 px-3 py-2 text-left transition-colors hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700/80 dark:hover:border-slate-600 dark:hover:bg-slate-800/60"
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={link.target}
|
||||
title={resolving ? label : `${label}(未收录)`}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-lg border border-dashed border-slate-200 px-3 py-2 dark:border-slate-700/80",
|
||||
!resolving && "opacity-70",
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
{!resolving ? (
|
||||
<span className="shrink-0 text-[11px] text-slate-300 dark:text-slate-600">
|
||||
未收录
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { ExternalLink, Sparkles } from "lucide-react";
|
||||
import { useEffect, useMemo, 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 { FloatingToc } from "./FloatingToc";
|
||||
import { MarkdownViewer } from "./MarkdownViewer";
|
||||
import { RelatedDocs } from "./RelatedDocs";
|
||||
import {
|
||||
SOURCE_TYPE_LABELS,
|
||||
STATUS_LABELS,
|
||||
downloadMarkdown,
|
||||
extractToc,
|
||||
formatDateTime,
|
||||
noteBreadcrumbSegments,
|
||||
splitRelatedSection,
|
||||
} 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: compact header + markdown reading area (or inline editor). */
|
||||
export function WikiContent({
|
||||
note,
|
||||
wikiLinks,
|
||||
onWikiLinkClick,
|
||||
}: {
|
||||
note: KnowledgeNote;
|
||||
wikiLinks?: WikiLinkMap;
|
||||
onWikiLinkClick: (link: WikiLinkTarget) => 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);
|
||||
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
||||
|
||||
// Pull the backend-injected `## Related` wikilinks out of the body so they
|
||||
// render as a structured module; editing still works on the full source.
|
||||
const { body, related } = useMemo(
|
||||
() => splitRelatedSection(note.content_md),
|
||||
[note.content_md],
|
||||
);
|
||||
const toc = useMemo(() => extractToc(body), [body]);
|
||||
// The floating outline only shows h2/h3 — deeper levels are noise.
|
||||
const outlineItems = useMemo(
|
||||
() => toc.filter((item) => item.level === 2 || item.level === 3),
|
||||
[toc],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setDraftTitle(note.title);
|
||||
setDraftTags(note.tags.join(", "));
|
||||
setDraftContent(note.content_md);
|
||||
setDraftStatus(note.status);
|
||||
scrollRef.current?.scrollTo({ top: 0 });
|
||||
setActiveHeadingId(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;
|
||||
}
|
||||
}
|
||||
setActiveHeadingId(current);
|
||||
});
|
||||
};
|
||||
|
||||
const handleTocClick = (id: string) => {
|
||||
setActiveHeadingId(id);
|
||||
document
|
||||
.getElementById(id)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
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">
|
||||
<header className="flex h-12 shrink-0 items-center gap-3 border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
|
||||
<Breadcrumb
|
||||
segments={noteBreadcrumbSegments(note)}
|
||||
current={note.title}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<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()}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{!editing ? (
|
||||
<FloatingToc
|
||||
items={outlineItems}
|
||||
activeId={activeHeadingId}
|
||||
onItemClick={handleTocClick}
|
||||
className="absolute right-4 top-3 z-10 sm:right-6"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={editing ? undefined : handleScroll}
|
||||
className="h-full overflow-y-auto"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[980px] px-5 pb-28 pt-8 sm:px-8 lg:px-10">
|
||||
{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-2 text-2xl font-bold leading-snug text-slate-900 dark:text-slate-100">
|
||||
{note.title}
|
||||
</h1>
|
||||
|
||||
{note.summary ? (
|
||||
<div className="my-4 rounded-lg border-l-2 border-slate-200 bg-slate-50 px-4 py-3 text-sm leading-7 text-slate-600 dark:border-slate-700 dark:bg-slate-800/50 dark:text-slate-300">
|
||||
{note.summary}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-7 mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<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-rose-50 px-2 text-xs font-medium text-rose-500 dark:bg-rose-500/10 dark:text-rose-300"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{note.entities && note.entities.length > 0 ? (
|
||||
<div className="mb-7 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={body}
|
||||
toc={toc}
|
||||
wikiLinks={wikiLinks}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
/>
|
||||
|
||||
<RelatedDocs
|
||||
links={related}
|
||||
wikiLinks={wikiLinks}
|
||||
onOpen={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-semibold text-slate-600 dark:text-slate-300">
|
||||
来源
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{note.sources.map((src) => (
|
||||
<li key={src.id} className="text-[13px]">
|
||||
{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="cursor-pointer 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-12 border-t border-slate-100 pt-3 text-[12px] text-slate-400/90 dark:border-slate-800 dark:text-slate-500">
|
||||
创建人 {actorLabel(note.created_by_name, note.created_by)} · 更新人{" "}
|
||||
{actorLabel(note.updated_by_name, note.updated_by)} · 更新于{" "}
|
||||
{formatDateTime(note.updated_at)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/* Wiki reading-area typography: overrides the generic MarkdownRenderer classes
|
||||
inside .wiki-markdown to get a document-site reading experience. */
|
||||
|
||||
.wiki-markdown h1 {
|
||||
font-size: 26px;
|
||||
line-height: 1.4;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.wiki-markdown h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin: 36px 0 14px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #eef1f5;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.wiki-markdown h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin: 26px 0 10px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.wiki-markdown h4 {
|
||||
font-size: 15.5px;
|
||||
font-weight: 600;
|
||||
margin: 22px 0 8px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.wiki-markdown p {
|
||||
font-size: 15.5px;
|
||||
line-height: 1.75;
|
||||
color: #334155;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.wiki-markdown ul,
|
||||
.wiki-markdown ol {
|
||||
padding-left: 22px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.wiki-markdown li {
|
||||
margin: 5px 0;
|
||||
line-height: 1.75;
|
||||
color: #334155;
|
||||
font-size: 15.5px;
|
||||
}
|
||||
|
||||
.wiki-markdown li > ul,
|
||||
.wiki-markdown li > ol {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.wiki-markdown strong {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wiki-markdown a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wiki-markdown a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.wiki-markdown blockquote {
|
||||
border-left: 3px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
padding: 10px 16px;
|
||||
color: #475569;
|
||||
margin: 16px 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: 20px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.wiki-markdown th,
|
||||
.wiki-markdown td {
|
||||
border: 1px solid #e8ecf1;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.wiki-markdown th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.wiki-markdown hr {
|
||||
margin: 28px 0;
|
||||
border-color: #eef1f5;
|
||||
}
|
||||
|
||||
/* Anchor target offset so headings clear the floating "目录" pill. */
|
||||
.wiki-markdown h1,
|
||||
.wiki-markdown h2,
|
||||
.wiki-markdown h3,
|
||||
.wiki-markdown h4 {
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
|
||||
/* 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 h2 {
|
||||
border-bottom-color: #1f2937;
|
||||
}
|
||||
|
||||
.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 a {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.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,236 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/** One entry parsed from the note's trailing `## Related` wikilink list. */
|
||||
export interface RelatedLink {
|
||||
target: string;
|
||||
alias?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the backend-injected `## Related` section (a plain list of
|
||||
* `- [[Title]]` bullets, see knowledge/service.py `_inject_related_notes`)
|
||||
* out of the markdown body so it can be rendered as a structured module
|
||||
* instead of a loose bullet list. If the section contains anything other
|
||||
* than wikilink bullets, the body is returned untouched (no data loss).
|
||||
*/
|
||||
export function splitRelatedSection(markdown: string): {
|
||||
body: string;
|
||||
related: RelatedLink[];
|
||||
} {
|
||||
const lines = markdown.split("\n");
|
||||
let inFence = false;
|
||||
let start = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/^\s*(```|~~~)/.test(lines[i])) {
|
||||
inFence = !inFence;
|
||||
continue;
|
||||
}
|
||||
if (inFence) continue;
|
||||
if (/^##\s+(Related|相关文档|相关)\s*$/i.test(lines[i].trim())) {
|
||||
start = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (start === -1) return { body: markdown, related: [] };
|
||||
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (/^#{1,2}\s+/.test(lines[i])) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const related: RelatedLink[] = [];
|
||||
for (const raw of lines.slice(start + 1, end)) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const match = /^[-*]\s*\[\[([^\]\n|]+)(?:\|([^\]\n]+))?\]\]\s*$/.exec(line);
|
||||
if (!match) return { body: markdown, related: [] };
|
||||
const target = match[1].trim();
|
||||
if (!related.some((r) => r.target === target)) {
|
||||
related.push({ target, alias: match[2]?.trim() || undefined });
|
||||
}
|
||||
}
|
||||
if (related.length === 0) return { body: markdown, related: [] };
|
||||
|
||||
const body = [...lines.slice(0, start), ...lines.slice(end)]
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trimEnd();
|
||||
return { body, related };
|
||||
}
|
||||
|
||||
/** 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("/")) {
|
||||
let segments = rawPath.split("/").slice(0, -1).filter(Boolean);
|
||||
// Vault notes live under projects/<project>/<category>/ — the first two
|
||||
// levels are constant scaffolding, so hide them from the directory tree.
|
||||
if (segments[0] === "projects" || segments[0] === "project") {
|
||||
segments = segments.slice(2);
|
||||
}
|
||||
if (segments.length > 0) return segments;
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -1,39 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon } from "lucide-react";
|
||||
|
||||
import { type Agent } from "@/core/agents";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AgentWelcome({
|
||||
className,
|
||||
agent,
|
||||
agentName,
|
||||
}: {
|
||||
className?: string;
|
||||
agent: Agent | null | undefined;
|
||||
agentName: string;
|
||||
}) {
|
||||
const displayName = desensitize(agent?.name ?? agentName);
|
||||
const description = agent?.description
|
||||
? desensitize(agent.description)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full flex-col items-center justify-center gap-2 px-8 py-4 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{displayName}</div>
|
||||
{description && (
|
||||
<p className="text-muted-foreground max-w-sm text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { BotIcon } from "lucide-react";
|
||||
|
||||
import { type Agent } from "@/core/agents";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Landing (home) hero shown above the input box before the first message of an
|
||||
* agent conversation: centered avatar, a "Hi,我是{name}" greeting and the
|
||||
* agent's description as a slogan. The description is clamped to two lines
|
||||
* (full text on hover) so a long blurb can never push the input box below the
|
||||
* fold. The agent's 常用问题 are rendered by the page as a list beside the
|
||||
* input box, not here.
|
||||
*/
|
||||
export function AgentWelcome({
|
||||
className,
|
||||
agent,
|
||||
agentName,
|
||||
}: {
|
||||
className?: string;
|
||||
agent: Agent | null | undefined;
|
||||
agentName: string;
|
||||
}) {
|
||||
const displayName = desensitize(agent?.name ?? agentName);
|
||||
const description = agent?.description
|
||||
? desensitize(agent.description)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full max-w-2xl flex-col items-center justify-center gap-3 px-6 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="bg-primary/10 ring-primary/15 mb-2 flex h-[72px] w-[72px] items-center justify-center rounded-3xl ring-1">
|
||||
<BotIcon className="text-primary h-9 w-9" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold tracking-tight">
|
||||
Hi,我是{displayName}
|
||||
</div>
|
||||
{description && (
|
||||
<p
|
||||
className="text-muted-foreground line-clamp-2 max-w-xl text-base leading-relaxed"
|
||||
title={description}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,9 @@ import {
|
||||
BotIcon,
|
||||
CopyIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
LockIcon,
|
||||
MessageSquareIcon,
|
||||
PinIcon,
|
||||
SearchIcon,
|
||||
ShieldIcon,
|
||||
SparklesIcon,
|
||||
SquarePenIcon,
|
||||
@@ -47,12 +45,24 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
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 { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
||||
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import {
|
||||
normalizeAgentQuestions,
|
||||
useAgent,
|
||||
useCreateAgent,
|
||||
useDeleteAgent,
|
||||
@@ -63,7 +73,7 @@ import {
|
||||
useUpdateAgent,
|
||||
} from "@/core/agents";
|
||||
import { getAgent } from "@/core/agents/api";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import type { Agent, AgentQuestion, UpdateAgentRequest } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
@@ -207,12 +217,21 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
const [editName, setEditName] = useState(agent.name);
|
||||
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
||||
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
||||
// Common questions (常用问题) — primary editing now lives in the agent chat
|
||||
// page's right-hand AgentDetailPanel; this dialog stays as a backup entry.
|
||||
// Each row has a short title (shown on the chip) and the full prompt sent
|
||||
// when clicked.
|
||||
const [editCommonQuestions, setEditCommonQuestions] = useState<AgentQuestion[]>(
|
||||
() => normalizeAgentQuestions(agent.common_questions),
|
||||
);
|
||||
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 ?? [],
|
||||
);
|
||||
const [skillSearch, setSkillSearch] = useState("");
|
||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||
|
||||
// The /api/agents list response intentionally omits `soul` (perf optimization
|
||||
@@ -249,13 +268,16 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
setEditDescription(agent.description ?? "");
|
||||
setEditPublished(agent.published);
|
||||
setEditIsPrivate(agent.is_private);
|
||||
setEditSquareId(agent.square_id ?? "");
|
||||
setSelectedSkills(agent.skills ?? []);
|
||||
setSkillSearch("");
|
||||
setEditCommonQuestions(normalizeAgentQuestions(agent.common_questions));
|
||||
}, [
|
||||
agent.common_questions,
|
||||
agent.description,
|
||||
agent.is_private,
|
||||
agent.name,
|
||||
agent.published,
|
||||
agent.square_id,
|
||||
agent.skills,
|
||||
editOpen,
|
||||
]);
|
||||
@@ -269,6 +291,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
// include `soul`) keep working.
|
||||
if (agentDetail) {
|
||||
setEditSoul(agentDetail.soul ?? "");
|
||||
setEditCommonQuestions(normalizeAgentQuestions(agentDetail.common_questions));
|
||||
} else {
|
||||
setEditSoul(agent.soul ?? "");
|
||||
}
|
||||
@@ -333,20 +356,40 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
toast.warning("私密广场与公开发布互斥,请只勾选其一");
|
||||
return;
|
||||
}
|
||||
// Private agents cannot also be published — force the value the
|
||||
// backend will see, regardless of any stale state in editPublished.
|
||||
const nextPublished = editIsPrivate ? false : editPublished;
|
||||
const nextQuestions = editCommonQuestions
|
||||
.map((q) => ({
|
||||
title: q.title.trim() || q.prompt.trim(),
|
||||
prompt: q.prompt.trim() || q.title.trim(),
|
||||
}))
|
||||
.filter((q) => q.prompt.length > 0);
|
||||
|
||||
// Only send the fields that actually changed: the backend merges partial
|
||||
// updates (model_fields_set), so e.g. a 常用问题 edit no longer rewrites
|
||||
// skills/SOUL and saving stays fast.
|
||||
const baselineQuestions = normalizeAgentQuestions(
|
||||
agentDetail?.common_questions ?? agent.common_questions,
|
||||
);
|
||||
const request: UpdateAgentRequest = {};
|
||||
if (trimmedName !== agent.name) request.name = trimmedName;
|
||||
if (editDescription !== (agent.description ?? "")) request.description = editDescription;
|
||||
if (editSoul !== (agentDetail?.soul ?? agent.soul ?? "")) request.soul = editSoul;
|
||||
if (nextPublished !== agent.published) request.published = nextPublished;
|
||||
if (editIsPrivate !== agent.is_private) request.is_private = editIsPrivate;
|
||||
if ((editSquareId || "") !== (agent.square_id ?? "")) request.square_id = editSquareId;
|
||||
if (JSON.stringify(selectedSkills) !== JSON.stringify(agent.skills ?? []))
|
||||
request.skills = selectedSkills;
|
||||
if (JSON.stringify(nextQuestions) !== JSON.stringify(baselineQuestions))
|
||||
request.common_questions = nextQuestions;
|
||||
|
||||
if (Object.keys(request).length === 0) {
|
||||
setEditOpen(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: {
|
||||
name: trimmedName,
|
||||
description: editDescription,
|
||||
soul: editSoul,
|
||||
// Private agents cannot also be published — force the value the
|
||||
// backend will see, regardless of any stale state in editPublished.
|
||||
published: editIsPrivate ? false : editPublished,
|
||||
is_private: editIsPrivate,
|
||||
skills: selectedSkills,
|
||||
},
|
||||
});
|
||||
await updateAgent.mutateAsync({ id: agent.id, request });
|
||||
toast.success("智能体已更新");
|
||||
setEditOpen(false);
|
||||
} catch (err) {
|
||||
@@ -362,6 +405,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,
|
||||
@@ -373,6 +422,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 {
|
||||
@@ -434,34 +498,11 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSkill(skillName: string) {
|
||||
setSelectedSkills((prev) => {
|
||||
if (prev.includes(skillName)) {
|
||||
return prev.filter((name) => name !== skillName);
|
||||
}
|
||||
return [...prev, skillName];
|
||||
});
|
||||
}
|
||||
|
||||
const enabledSkills = useMemo(
|
||||
() => skills.filter((skill) => skill.enabled),
|
||||
[skills],
|
||||
);
|
||||
|
||||
// Search over English name, Chinese name (name_zh) and description, so both
|
||||
// 中英文 work. Already-selected skills stay searchable too (selection is
|
||||
// independent of this filter).
|
||||
const filteredSkills = useMemo(() => {
|
||||
const q = skillSearch.trim().toLowerCase();
|
||||
if (!q) return enabledSkills;
|
||||
return enabledSkills.filter(
|
||||
(skill) =>
|
||||
skill.name.toLowerCase().includes(q) ||
|
||||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
|
||||
(skill.description ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [enabledSkills, skillSearch]);
|
||||
|
||||
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
|
||||
// both the card header and the compact list row.
|
||||
const statusBadges = (
|
||||
@@ -831,7 +872,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑智能体:{desensitize(agent.name)}</DialogTitle>
|
||||
<DialogDescription>
|
||||
可以修改名称、描述、技能白名单、SOUL 内容与发布状态。
|
||||
可以修改名称、描述、技能白名单、SOUL 内容、常用问题与发布状态。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
@@ -859,65 +900,13 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
已选 {selectedSkills.length} 个
|
||||
</span>
|
||||
</div>
|
||||
{enabledSkills.length > 0 && (
|
||||
<div className="relative">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="搜索技能(支持中英文名称、描述)…"
|
||||
value={skillSearch}
|
||||
onChange={(e) => setSkillSearch(e.currentTarget.value)}
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
|
||||
{isSkillsLoading ? (
|
||||
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
||||
) : enabledSkills.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
当前没有可用技能
|
||||
</p>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">没有匹配的技能</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{filteredSkills.map((skill) => {
|
||||
const checked = selectedSkills.includes(skill.name);
|
||||
return (
|
||||
<div
|
||||
key={skill.name}
|
||||
className="flex w-full min-w-0 items-center"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={checked ? "default" : "outline"}
|
||||
onClick={() => toggleSkill(skill.name)}
|
||||
title={desensitize(skill.description ?? "")}
|
||||
className="h-8 min-w-0 flex-1 rounded-r-none"
|
||||
>
|
||||
<span className="truncate">
|
||||
{skill.name_zh?.trim()
|
||||
? skill.name_zh
|
||||
: desensitize(skill.name)}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant={checked ? "default" : "outline"}
|
||||
onClick={() => setDetailSkillName(skill.name)}
|
||||
title={t.settings.skills.viewDetailAction}
|
||||
className="h-8 w-7 rounded-l-none border-l-0"
|
||||
>
|
||||
<InfoIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SkillTransferSelector
|
||||
skills={enabledSkills}
|
||||
selected={selectedSkills}
|
||||
onChange={setSelectedSkills}
|
||||
isLoading={isSkillsLoading}
|
||||
onViewDetail={setDetailSkillName}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
不选任何技能会保存为无技能(skills = [])。
|
||||
</p>
|
||||
@@ -932,7 +921,17 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
? "正在加载 SOUL.md..."
|
||||
: "输入 SOUL.md 内容"
|
||||
}
|
||||
className="min-h-40 font-mono text-xs"
|
||||
className="max-h-[45vh] min-h-48 resize-y font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">常用问题</label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
这些问题会在该智能体的首页与简介栏作为推荐问题展示,点击即可一键提问。
|
||||
</p>
|
||||
<AgentQuestionEditor
|
||||
value={editCommonQuestions}
|
||||
onChange={setEditCommonQuestions}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
@@ -956,6 +955,32 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{editPublished && !editIsPrivate && (publishSquares?.squares.length ?? 0) > 0 && (
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">发布广场</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
选择发布到哪个广场;不选则归入默认广场。每个智能体只能归属一个广场。
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={editSquareId || publishSquares?.default_square_id || ""}
|
||||
onValueChange={setEditSquareId}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-44 text-sm">
|
||||
<SelectValue placeholder="默认广场" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{publishSquares?.squares.map((sq) => (
|
||||
<SelectItem key={sq.id} value={sq.id}>
|
||||
{sq.name}
|
||||
{sq.id === publishSquares?.default_square_id ? "(默认)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">加入私密广场</p>
|
||||
@@ -1034,6 +1059,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="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,878 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
CheckIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
LockIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
PanelRightCloseIcon,
|
||||
PanelRightOpenIcon,
|
||||
PlusIcon,
|
||||
ShieldIcon,
|
||||
SparklesIcon,
|
||||
SquarePenIcon,
|
||||
Trash2Icon,
|
||||
UserRoundIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
||||
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
|
||||
import { SkillPickerSheet } from "@/components/workspace/agents/skill-picker-sheet";
|
||||
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import {
|
||||
normalizeAgentQuestions,
|
||||
useDeleteAgent,
|
||||
useSetAgentFeatured,
|
||||
useUpdateAgent,
|
||||
type Agent,
|
||||
type AgentQuestion,
|
||||
type UpdateAgentRequest,
|
||||
} from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { usePublishSquares } from "@/core/system-settings/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Turn the admin-authored SOUL.md markdown into a clean, readable profile blurb
|
||||
* for the narrow panel. The view mode is a profile card, not a document view —
|
||||
* so we strip markdown syntax (headings, emphasis, inline code, list bullets,
|
||||
* blockquotes) rather than rendering it, keeping the line structure intact.
|
||||
*/
|
||||
function cleanProfileText(raw: string): string {
|
||||
return raw
|
||||
.split("\n")
|
||||
.map((line) =>
|
||||
line
|
||||
.replace(/^\s{0,3}#{1,6}\s*/, "")
|
||||
.replace(/^\s{0,3}>\s?/, "")
|
||||
.replace(/^(\s*)[-*+]\s+/, "$1• ")
|
||||
.replace(/(\*\*|__)(.*?)\1/g, "$2")
|
||||
.replace(/(\*|_)(.*?)\1/g, "$2")
|
||||
.replace(/`([^`]+)`/g, "$1"),
|
||||
)
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Local edit-mode draft — committed in one `updateAgent` call on 保存. */
|
||||
interface AgentDraft {
|
||||
name: string;
|
||||
description: string;
|
||||
soul: string;
|
||||
questions: AgentQuestion[];
|
||||
skills: string[];
|
||||
published: boolean;
|
||||
featured: boolean;
|
||||
}
|
||||
|
||||
function draftFromAgent(agent: Agent): AgentDraft {
|
||||
return {
|
||||
name: agent.name,
|
||||
description: agent.description ?? "",
|
||||
soul: agent.soul ?? "",
|
||||
questions: normalizeAgentQuestions(agent.common_questions),
|
||||
skills: agent.skills ?? [],
|
||||
published: agent.published,
|
||||
featured: agent.featured_order != null,
|
||||
};
|
||||
}
|
||||
|
||||
function Section({
|
||||
icon,
|
||||
title,
|
||||
extra,
|
||||
children,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
extra?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="bg-card/60 rounded-xl border p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
|
||||
{icon}
|
||||
{title}
|
||||
</h4>
|
||||
{extra}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能体详情面板 — the right-hand agent configuration center on the agent chat
|
||||
* page. View mode shows the full profile (avatar, name, description, SOUL,
|
||||
* 常用问题, enabled skills, publish/featured state); 管理员或发布者 can flip
|
||||
* into an inline edit mode (no dialog) that edits everything locally and
|
||||
* commits in a single 保存. 添加技能 opens a secondary drawer (SkillPickerSheet).
|
||||
* Everyone else gets a read-only profile. On narrow screens (<lg) the panel
|
||||
* collapses into a rail whose button opens the same content in a Sheet, so it
|
||||
* never overlaps the chat input.
|
||||
*/
|
||||
export function AgentDetailPanel({
|
||||
agent,
|
||||
agentName,
|
||||
onAskQuestion,
|
||||
disabled,
|
||||
}: {
|
||||
agent: Agent | null | undefined;
|
||||
agentName: string;
|
||||
onAskQuestion: (text: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<AgentDraft | null>(null);
|
||||
const [editingQuestionIndex, setEditingQuestionIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [questionDraft, setQuestionDraft] = useState<AgentQuestion>({
|
||||
title: "",
|
||||
prompt: "",
|
||||
});
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
|
||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||
|
||||
const updateAgent = useUpdateAgent();
|
||||
const deleteAgent = useDeleteAgent();
|
||||
const setFeatured = useSetAgentFeatured();
|
||||
const { data: publishSquares } = usePublishSquares();
|
||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||
// Same pool the edit dialog offers: only globally enabled skills are
|
||||
// whitelist candidates.
|
||||
const enabledPool = useMemo(
|
||||
() => skills.filter((skill) => skill.enabled),
|
||||
[skills],
|
||||
);
|
||||
|
||||
// Permission model mirrors agent-card: admins manage the full catalog,
|
||||
// owners manage their own agents, everyone else is read-only.
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
const isAdmin = storedAuth?.system_role === "admin";
|
||||
const isOwned =
|
||||
!!agent &&
|
||||
!agent.builtin &&
|
||||
agent.user_id !== null &&
|
||||
agent.user_id === currentUserId;
|
||||
const canEdit = !!agent && (isOwned || isAdmin);
|
||||
// Built-ins are inherently visible to everyone, so publish toggle is moot.
|
||||
const canTogglePublish = !!agent && (isOwned || (isAdmin && !agent.builtin));
|
||||
|
||||
const isSaving = updateAgent.isPending || setFeatured.isPending;
|
||||
|
||||
const displayName = desensitize(agent?.name ?? agentName);
|
||||
const questions = useMemo(
|
||||
() => normalizeAgentQuestions(agent?.common_questions),
|
||||
[agent?.common_questions],
|
||||
);
|
||||
const soulPreview = agent?.soul?.trim()
|
||||
? cleanProfileText(desensitize(agent.soul))
|
||||
: undefined;
|
||||
|
||||
function beginEdit() {
|
||||
if (!agent || !canEdit) return;
|
||||
cancelQuestionEdit();
|
||||
setDraft(draftFromAgent(agent));
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditing(false);
|
||||
setDraft(null);
|
||||
setPickerOpen(false);
|
||||
}
|
||||
|
||||
function beginQuestionEdit(index: number, question: AgentQuestion) {
|
||||
if (!canEdit) return;
|
||||
setEditingQuestionIndex(index);
|
||||
setQuestionDraft(question);
|
||||
}
|
||||
|
||||
function cancelQuestionEdit() {
|
||||
setEditingQuestionIndex(null);
|
||||
setQuestionDraft({ title: "", prompt: "" });
|
||||
}
|
||||
|
||||
async function saveQuestionEdit(index: number) {
|
||||
if (!agent) return;
|
||||
const nextQuestions = questions.map((question, questionIndex) =>
|
||||
questionIndex === index
|
||||
? {
|
||||
title: questionDraft.title.trim() || questionDraft.prompt.trim(),
|
||||
prompt: questionDraft.prompt.trim() || questionDraft.title.trim(),
|
||||
}
|
||||
: question,
|
||||
);
|
||||
const cleanedQuestions = nextQuestions.filter(
|
||||
(question) => question.prompt.trim().length > 0,
|
||||
);
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: { common_questions: cleanedQuestions },
|
||||
});
|
||||
toast.success("Updated");
|
||||
cancelQuestionEdit();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!agent || !draft) return;
|
||||
const trimmedName = draft.name.trim();
|
||||
if (!trimmedName) {
|
||||
toast.error("名称不能为空");
|
||||
return;
|
||||
}
|
||||
// 私密广场与公开发布互斥 — force what the backend will accept.
|
||||
const nextPublished = agent.is_private ? false : draft.published;
|
||||
const nextQuestions = draft.questions
|
||||
.map((q) => ({
|
||||
title: q.title.trim() || q.prompt.trim(),
|
||||
prompt: q.prompt.trim() || q.title.trim(),
|
||||
}))
|
||||
.filter((q) => q.prompt.length > 0);
|
||||
// Only send the fields that actually changed: the backend merges partial
|
||||
// updates (model_fields_set), so e.g. editing 常用问题 alone no longer
|
||||
// re-saves skills/SOUL/发布状态 and the save returns quickly.
|
||||
const request: UpdateAgentRequest = {};
|
||||
if (trimmedName !== agent.name) request.name = trimmedName;
|
||||
if (draft.description !== (agent.description ?? ""))
|
||||
request.description = draft.description;
|
||||
if (draft.soul !== (agent.soul ?? "")) request.soul = draft.soul;
|
||||
if (nextPublished !== agent.published) request.published = nextPublished;
|
||||
if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
|
||||
request.skills = draft.skills;
|
||||
if (
|
||||
JSON.stringify(nextQuestions) !==
|
||||
JSON.stringify(normalizeAgentQuestions(agent.common_questions))
|
||||
)
|
||||
request.common_questions = nextQuestions;
|
||||
try {
|
||||
if (Object.keys(request).length > 0) {
|
||||
await updateAgent.mutateAsync({ id: agent.id, request });
|
||||
}
|
||||
// 首页推荐 rides a dedicated endpoint — only call it when toggled.
|
||||
const wasFeatured = agent.featured_order != null;
|
||||
if (isAdmin && draft.featured !== wasFeatured) {
|
||||
if (draft.featured && !nextPublished) {
|
||||
toast.warning("只有已发布的智能体可以加入首页推荐,已跳过该项");
|
||||
} else {
|
||||
await setFeatured.mutateAsync({
|
||||
id: agent.id,
|
||||
order: draft.featured ? Date.now() : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
toast.success("智能体已更新");
|
||||
setEditing(false);
|
||||
setDraft(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTogglePublished() {
|
||||
if (!agent) return;
|
||||
if (!agent.published && agent.is_private) {
|
||||
toast.warning("该智能体已加入私密广场,不可发布");
|
||||
return;
|
||||
}
|
||||
// Publishing: pick a target square when any are configured.
|
||||
if (!agent.published && (publishSquares?.squares.length ?? 0) > 0) {
|
||||
setPublishPickerOpen(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: { published: !agent.published },
|
||||
});
|
||||
toast.success(agent.published ? "已下架" : "已发布");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublishToSquare(squareId: string) {
|
||||
if (!agent) return;
|
||||
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 handleDelete() {
|
||||
if (!agent) return;
|
||||
try {
|
||||
await deleteAgent.mutateAsync(agent.id);
|
||||
toast.success("智能体已删除");
|
||||
setDeleteOpen(false);
|
||||
router.push("/page/workspace/agents");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
const statusBadges = agent && (
|
||||
<>
|
||||
{agent.builtin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
内置
|
||||
</Badge>
|
||||
)}
|
||||
{agent.published && !agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
>
|
||||
<GlobeIcon className="size-3" />
|
||||
已发布
|
||||
</Badge>
|
||||
)}
|
||||
{!agent.published && !agent.builtin && (
|
||||
<Badge variant="outline" className="text-muted-foreground text-xs">
|
||||
未发布
|
||||
</Badge>
|
||||
)}
|
||||
{agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
||||
>
|
||||
<ShieldIcon className="size-3" />
|
||||
私密
|
||||
</Badge>
|
||||
)}
|
||||
{agent.featured_order != null && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
|
||||
>
|
||||
<SparklesIcon className="size-3" />
|
||||
首页推荐
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const headerActions = canEdit ? (
|
||||
editing ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" onClick={() => void saveEdit()} disabled={isSaving}>
|
||||
{isSaving && <Loader2Icon className="size-3.5 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={cancelEdit}
|
||||
disabled={isSaving}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="hidden"
|
||||
onClick={beginEdit}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<SquarePenIcon className="size-3.5" />
|
||||
编辑
|
||||
</Button>
|
||||
{canTogglePublish && (
|
||||
<Tooltip content={agent?.published ? "下架" : "发布"}>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => void handleTogglePublished()}
|
||||
disabled={updateAgent.isPending}
|
||||
aria-label={agent?.published ? "下架" : "发布"}
|
||||
>
|
||||
{agent?.published ? (
|
||||
<LockIcon className="size-3.5" />
|
||||
) : (
|
||||
<GlobeIcon className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content="删除智能体">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
aria-label="删除智能体"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
) : null;
|
||||
|
||||
const skillNamesShown = editing && draft ? draft.skills : (agent?.skills ?? []);
|
||||
|
||||
const body = !agent ? (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center gap-2 text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
智能体信息加载中…
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 px-4 py-4">
|
||||
{/* ── Identity / 基础信息 ─────────────────────────────────────────── */}
|
||||
<section className="bg-card/60 rounded-xl border p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-primary/10 ring-primary/15 flex size-12 shrink-0 items-center justify-center rounded-2xl ring-1">
|
||||
<BotIcon className="text-primary size-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
{editing && draft ? (
|
||||
<Input
|
||||
value={draft.name}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, name: e.currentTarget.value })
|
||||
}
|
||||
placeholder="智能体名称"
|
||||
className="h-8 text-sm font-semibold"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="truncate text-base font-bold"
|
||||
title={displayName}
|
||||
>
|
||||
{displayName}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{statusBadges}
|
||||
</div>
|
||||
{!editing && agent.owner_username && !agent.builtin && (
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<UserRoundIcon className="size-3 shrink-0" />
|
||||
发布者:{desensitize(agent.owner_username)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
{editing && draft ? (
|
||||
<Textarea
|
||||
value={draft.description}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, description: e.currentTarget.value })
|
||||
}
|
||||
placeholder="智能体描述"
|
||||
className="min-h-16 text-sm"
|
||||
/>
|
||||
) : agent.description ? (
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{desensitize(agent.description)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground/60 text-sm">暂无描述</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Publish / featured toggles — only meaningful in edit mode. */}
|
||||
{editing && draft && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">公开发布</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{agent.is_private
|
||||
? "已加入私密广场,不可发布"
|
||||
: "打开后对所有用户可见"}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.published && !agent.is_private}
|
||||
disabled={agent.is_private}
|
||||
onCheckedChange={(v) => setDraft({ ...draft, published: v })}
|
||||
/>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">首页推荐</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
加入首页选择器(需已发布)
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.featured}
|
||||
onCheckedChange={(v) => setDraft({ ...draft, featured: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── SOUL / 人设 ────────────────────────────────────────────────── */}
|
||||
{(editing || soulPreview) && (
|
||||
<Section
|
||||
icon={<SparklesIcon className="size-3.5" />}
|
||||
title="人设(SOUL)"
|
||||
>
|
||||
{editing && draft ? (
|
||||
<Textarea
|
||||
value={draft.soul}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, soul: e.currentTarget.value })
|
||||
}
|
||||
placeholder="输入 SOUL.md 内容"
|
||||
className="max-h-[45vh] min-h-48 resize-y font-mono text-xs"
|
||||
/>
|
||||
) : (
|
||||
// Viewport-relative cap: the SOUL preview expands with the screen
|
||||
// (taller than the old fixed 240px box) but never swallows the
|
||||
// whole panel — long content still scrolls inside.
|
||||
<p className="text-muted-foreground max-h-[40vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{soulPreview}
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
|
||||
{(editing || questions.length > 0) && (
|
||||
<Section
|
||||
icon={<MessageCircleQuestionIcon className="size-3.5" />}
|
||||
title="常用问题"
|
||||
extra={
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{(editing && draft ? draft.questions : questions).length} 个
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{editing && draft ? (
|
||||
<AgentQuestionEditor
|
||||
value={draft.questions}
|
||||
onChange={(next) => setDraft({ ...draft, questions: next })}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{questions.map((question, index) => {
|
||||
const isEditingThis = editingQuestionIndex === index;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="border-border/60 bg-background/60 rounded-xl border p-2"
|
||||
>
|
||||
{isEditingThis ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={questionDraft.title}
|
||||
disabled={isSaving}
|
||||
placeholder="Title"
|
||||
onChange={(e) =>
|
||||
setQuestionDraft({
|
||||
...questionDraft,
|
||||
title: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
className="h-8 text-sm font-medium"
|
||||
/>
|
||||
<Textarea
|
||||
value={questionDraft.prompt}
|
||||
disabled={isSaving}
|
||||
placeholder="Prompt"
|
||||
onChange={(e) =>
|
||||
setQuestionDraft({
|
||||
...questionDraft,
|
||||
prompt: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
className="min-h-16 resize-none text-sm"
|
||||
/>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={cancelQuestionEdit}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void saveQuestionEdit(index)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<CheckIcon className="size-3.5" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onAskQuestion(question.prompt)}
|
||||
title={desensitize(question.prompt)}
|
||||
className={cn(
|
||||
"hover:bg-accent min-w-0 flex-1 rounded-lg px-2 py-1.5 text-left text-sm leading-relaxed transition-colors",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="block truncate">
|
||||
{desensitize(question.title)}
|
||||
</span>
|
||||
</button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => beginQuestionEdit(index, question)}
|
||||
disabled={isSaving}
|
||||
aria-label="Edit question"
|
||||
>
|
||||
<SquarePenIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
|
||||
<Section
|
||||
icon={<SparklesIcon className="size-3.5" />}
|
||||
title={`已启用技能 ${skillNamesShown.length} 个`}
|
||||
extra={
|
||||
editing ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
<PlusIcon className="size-3.5" />
|
||||
添加技能
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<EnabledSkillList
|
||||
skillNames={skillNamesShown}
|
||||
skills={skills}
|
||||
isLoading={isSkillsLoading}
|
||||
editing={editing}
|
||||
onRemove={
|
||||
editing && draft
|
||||
? (name) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
skills: draft.skills.filter((n) => n !== name),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onViewDetail={setDetailSkillName}
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
|
||||
const panelChrome = (collapseButton: ReactNode) => (
|
||||
<>
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<span className="text-sm font-semibold">智能体信息</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{headerActions}
|
||||
{collapseButton}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
|
||||
{collapsed ? (
|
||||
<div className="hidden shrink-0 flex-col items-center border-l pt-3 lg:flex">
|
||||
<Tooltip content="智能体信息">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setCollapsed(false)}
|
||||
aria-label="展开智能体信息"
|
||||
>
|
||||
<PanelRightOpenIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<aside className="bg-card/40 hidden h-full w-[360px] shrink-0 flex-col border-l lg:flex">
|
||||
{panelChrome(
|
||||
<Tooltip content="收起">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setCollapsed(true)}
|
||||
aria-label="收起智能体信息"
|
||||
>
|
||||
<PanelRightCloseIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>,
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* ── Narrow screens (<lg): rail trigger + drawer with the same body ── */}
|
||||
<div className="flex shrink-0 flex-col items-center border-l pt-3 lg:hidden">
|
||||
<Tooltip content="智能体信息">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label="打开智能体信息"
|
||||
>
|
||||
<InfoIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
|
||||
<SheetHeader className="border-b px-4 pb-3 pt-4">
|
||||
<SheetTitle className="flex items-center justify-between text-sm">
|
||||
智能体信息
|
||||
<span className="mr-6">{headerActions}</span>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* ── Secondary surfaces ───────────────────────────────────────────── */}
|
||||
<SkillPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
skills={enabledPool}
|
||||
isLoading={isSkillsLoading}
|
||||
selected={editing && draft ? draft.skills : []}
|
||||
onChange={(next) => {
|
||||
if (editing && draft) setDraft({ ...draft, skills: next });
|
||||
}}
|
||||
onViewDetail={setDetailSkillName}
|
||||
/>
|
||||
|
||||
<SkillDetailDialog
|
||||
skillName={detailSkillName}
|
||||
open={detailSkillName !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setDetailSkillName(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除智能体</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除「{displayName}」吗?该操作不可恢复。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteOpen(false)}
|
||||
disabled={deleteAgent.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleteAgent.isPending}
|
||||
>
|
||||
{deleteAgent.isPending ? "删除中…" : "删除"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<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="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
||||
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent, AgentSort } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { usePrivateSquareStatus, useViewMode } from "@/core/system-settings/hooks";
|
||||
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
|
||||
@@ -30,21 +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");
|
||||
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.
|
||||
@@ -52,9 +52,22 @@ export function AgentGallery() {
|
||||
// filters change, every tab returns to its first page.
|
||||
const tagKey = tagIds.join(",");
|
||||
useEffect(() => {
|
||||
setPages({ all: 1, mine: 1, private: 1 });
|
||||
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;
|
||||
const privateFeatureUsable =
|
||||
@@ -68,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,
|
||||
@@ -84,6 +98,8 @@ export function AgentGallery() {
|
||||
pageSize: PAGE_SIZE,
|
||||
enabled: activeTab !== "private",
|
||||
owner: publisher?.id,
|
||||
// Each square tab fetches only that square's agents.
|
||||
square: isSquareTab ? activeTab : undefined,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -119,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 = () => {
|
||||
@@ -174,7 +190,7 @@ export function AgentGallery() {
|
||||
list: Agent[];
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
tab: "all" | "mine" | "private";
|
||||
tab: string;
|
||||
emptyDescription: string;
|
||||
showNewButton?: boolean;
|
||||
}) => {
|
||||
@@ -195,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)}
|
||||
@@ -324,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.
|
||||
@@ -337,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" />
|
||||
@@ -414,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,120 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
PlusIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { AgentQuestion } from "@/core/agents";
|
||||
|
||||
/**
|
||||
* Editable list of an agent's 常用问题 (recommended questions). Each row has a
|
||||
* short `title` (the chip label) and the full `prompt` sent on click. Supports
|
||||
* add / remove / inline edit / reorder (move up & down). Pure controlled
|
||||
* component — persistence stays with the caller (edit dialog or detail panel).
|
||||
*/
|
||||
export function AgentQuestionEditor({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: AgentQuestion[];
|
||||
onChange: (next: AgentQuestion[]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
function update(index: number, patch: Partial<AgentQuestion>) {
|
||||
const next = [...value];
|
||||
next[index] = { ...next[index]!, ...patch };
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
onChange(value.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function move(index: number, delta: -1 | 1) {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= value.length) return;
|
||||
const next = [...value];
|
||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{value.length === 0 && (
|
||||
<p className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-xs">
|
||||
尚未配置常用问题,点击下方按钮添加。
|
||||
</p>
|
||||
)}
|
||||
{value.map((item, index) => (
|
||||
<div key={index} className="bg-background/60 space-y-1.5 rounded-lg border p-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={item.title}
|
||||
disabled={disabled}
|
||||
placeholder="标题(如:能蒸馏哪些人)"
|
||||
onChange={(e) => update(index, { title: e.currentTarget.value })}
|
||||
className="h-8 text-sm font-medium"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={disabled || index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
aria-label="上移"
|
||||
title="上移"
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={disabled || index === value.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
aria-label="下移"
|
||||
title="下移"
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={disabled}
|
||||
onClick={() => remove(index)}
|
||||
aria-label="删除问题"
|
||||
title="删除问题"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={item.prompt}
|
||||
disabled={disabled}
|
||||
placeholder="点击后实际发送的完整问题内容…"
|
||||
onChange={(e) => update(index, { prompt: e.currentTarget.value })}
|
||||
className="min-h-16 resize-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange([...value, { title: "", prompt: "" }])}
|
||||
>
|
||||
<PlusIcon className="size-3.5" /> 添加问题
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { InboxIcon, InfoIcon, XIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Skill } from "@/core/skills";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Chinese display label with English-name fallback. */
|
||||
export function skillDisplayLabel(skill: Skill) {
|
||||
return skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name);
|
||||
}
|
||||
|
||||
/** First character of the display label — the letter avatar on skill cards. */
|
||||
export function skillInitial(skill: Skill) {
|
||||
const label = skill.name_zh?.trim() || skill.name;
|
||||
return label.trim().charAt(0).toUpperCase() || "?";
|
||||
}
|
||||
|
||||
/** Human label for the skill's provenance (内置 / 用户上传 / 对话沉淀). */
|
||||
export function skillSourceLabel(skill: Skill): string | null {
|
||||
if (skill.builtin) return "内置";
|
||||
if (skill.source === "upload") return "用户上传";
|
||||
if (skill.source === "agent") return "对话沉淀";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent's enabled-skill list shown in the detail panel: one card per
|
||||
* whitelisted skill name with a letter avatar, Chinese name, category/source
|
||||
* badges and the short description. In editing mode each card gains a remove
|
||||
* button. Skill names with no metadata in the pool (e.g. a skill that was
|
||||
* deleted after being whitelisted) still render as a degraded card so the
|
||||
* whitelist stays visible and removable.
|
||||
*/
|
||||
export function EnabledSkillList({
|
||||
skillNames,
|
||||
skills,
|
||||
editing,
|
||||
onRemove,
|
||||
onViewDetail,
|
||||
isLoading,
|
||||
}: {
|
||||
/** The agent's persisted whitelist — `agent.skills` (English names). */
|
||||
skillNames: string[];
|
||||
/** Full skill pool used to resolve metadata for each name. */
|
||||
skills: Skill[];
|
||||
editing?: boolean;
|
||||
onRemove?: (name: string) => void;
|
||||
onViewDetail?: (name: string) => void;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const byName = useMemo(
|
||||
() => new Map(skills.map((s) => [s.name, s])),
|
||||
[skills],
|
||||
);
|
||||
|
||||
if (isLoading && skillNames.length > 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-xs">
|
||||
技能加载中…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (skillNames.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-1.5 rounded-lg border border-dashed px-3 py-5 text-xs">
|
||||
<InboxIcon className="size-5 opacity-50" />
|
||||
<span>尚未启用任何技能</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{skillNames.map((name) => {
|
||||
const skill = byName.get(name);
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className={cn(
|
||||
"group/skill bg-background/60 flex items-start gap-2.5 rounded-xl border p-2.5 transition-colors",
|
||||
onViewDetail && "hover:border-primary/40 hover:bg-accent/40",
|
||||
)}
|
||||
>
|
||||
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-semibold">
|
||||
{skill ? skillInitial(skill) : "?"}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
className="min-w-0 truncate text-sm font-medium"
|
||||
title={skill ? skillDisplayLabel(skill) : desensitize(name)}
|
||||
>
|
||||
{skill ? skillDisplayLabel(skill) : desensitize(name)}
|
||||
</span>
|
||||
{skill?.category?.trim() && (
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px]">
|
||||
{skill.category}
|
||||
</Badge>
|
||||
)}
|
||||
{skill && skillSourceLabel(skill) && (
|
||||
<Badge variant="outline" className="shrink-0 text-[10px]">
|
||||
{skillSourceLabel(skill)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
|
||||
{skill
|
||||
? skill.description?.trim()
|
||||
? desensitize(skill.description)
|
||||
: "暂无描述"
|
||||
: "该技能已不存在或不可用,可将其移除。"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{skill && onViewDetail && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground size-7"
|
||||
onClick={() => onViewDetail(name)}
|
||||
title="查看技能详情"
|
||||
aria-label="查看技能详情"
|
||||
>
|
||||
<InfoIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{editing && onRemove && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-destructive size-7"
|
||||
onClick={() => onRemove(name)}
|
||||
title="移除技能"
|
||||
aria-label="移除技能"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
skillDisplayLabel,
|
||||
skillInitial,
|
||||
skillSourceLabel,
|
||||
} from "@/components/workspace/agents/enabled-skill-list";
|
||||
import type { Skill } from "@/core/skills";
|
||||
import {
|
||||
useInstallSkillUpload,
|
||||
useValidateSkillUpload,
|
||||
} from "@/core/skills/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ALL_CATEGORIES = "__all__";
|
||||
|
||||
type SourceFilter = "all" | "builtin" | "upload" | "agent";
|
||||
|
||||
const SOURCE_OPTIONS: { value: SourceFilter; label: string }[] = [
|
||||
{ value: "all", label: "全部来源" },
|
||||
{ value: "builtin", label: "内置技能" },
|
||||
{ value: "upload", label: "用户上传" },
|
||||
{ value: "agent", label: "对话沉淀" },
|
||||
];
|
||||
|
||||
function matchesSource(skill: Skill, source: SourceFilter) {
|
||||
switch (source) {
|
||||
case "all":
|
||||
return true;
|
||||
case "builtin":
|
||||
return !!skill.builtin;
|
||||
case "upload":
|
||||
return skill.source === "upload";
|
||||
case "agent":
|
||||
return skill.source === "agent";
|
||||
}
|
||||
}
|
||||
|
||||
function matchesQuery(skill: Skill, q: string) {
|
||||
if (!q) return true;
|
||||
return (
|
||||
skill.name.toLowerCase().includes(q) ||
|
||||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
|
||||
(skill.description ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 技能选择器 — a secondary right-hand drawer opened from the agent detail
|
||||
* panel's "添加技能" button. Search (中英文名称 + 描述), category tabs, source
|
||||
* filter (内置 / 用户上传 / 对话沉淀), skill-pack upload, card grid with
|
||||
* enable/disable switches, bulk add-filtered / clear-selected, and a footer
|
||||
* showing the live selected count. Selection is controlled by the caller (the
|
||||
* panel's local draft) — nothing here persists until the panel saves.
|
||||
*/
|
||||
export function SkillPickerSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
skills,
|
||||
isLoading,
|
||||
selected,
|
||||
onChange,
|
||||
onViewDetail,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Selectable pool (caller pre-filters by `enabled`). */
|
||||
skills: Skill[];
|
||||
isLoading?: boolean;
|
||||
/** Currently selected skill names — the draft `skills: string[]`. */
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
onViewDetail?: (name: string) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [category, setCategory] = useState<string>(ALL_CATEGORIES);
|
||||
const [source, setSource] = useState<SourceFilter>("all");
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const validateUpload = useValidateSkillUpload();
|
||||
const installUpload = useInstallSkillUpload();
|
||||
const isUploading = validateUpload.isPending || installUpload.isPending;
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const skill of skills) {
|
||||
if (skill.category?.trim()) set.add(skill.category.trim());
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
}, [skills]);
|
||||
|
||||
const query = search.trim().toLowerCase();
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
skills.filter(
|
||||
(skill) =>
|
||||
matchesQuery(skill, query) &&
|
||||
matchesSource(skill, source) &&
|
||||
(category === ALL_CATEGORIES || skill.category?.trim() === category),
|
||||
),
|
||||
[skills, query, source, category],
|
||||
);
|
||||
|
||||
const filteredUnselected = useMemo(
|
||||
() => filtered.filter((skill) => !selectedSet.has(skill.name)),
|
||||
[filtered, selectedSet],
|
||||
);
|
||||
|
||||
function toggle(name: string) {
|
||||
if (selectedSet.has(name)) {
|
||||
onChange(selected.filter((n) => n !== name));
|
||||
} else {
|
||||
onChange([...selected, name]);
|
||||
}
|
||||
}
|
||||
|
||||
function addAllFiltered() {
|
||||
if (filteredUnselected.length === 0) return;
|
||||
onChange([...selected, ...filteredUnselected.map((s) => s.name)]);
|
||||
}
|
||||
|
||||
function clearSelected() {
|
||||
if (selected.length === 0) return;
|
||||
onChange([]);
|
||||
}
|
||||
|
||||
async function handleUploadFile(file: File) {
|
||||
const lower = file.name.toLowerCase();
|
||||
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
|
||||
toast.error("仅支持 .zip / .skill 技能包");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const validation = await validateUpload.mutateAsync(file);
|
||||
if (validation.conflict) {
|
||||
toast.warning(
|
||||
`已存在同名技能「${validation.skill_name}」,请到「技能」设置页上传以选择是否覆盖。`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = await installUpload.mutateAsync({
|
||||
file,
|
||||
targetCategory: "custom",
|
||||
});
|
||||
// Auto-select the freshly installed skill so the user doesn't have to
|
||||
// hunt for it in the refreshed pool.
|
||||
if (!selectedSet.has(result.skill_name)) {
|
||||
onChange([...selected, result.skill_name]);
|
||||
}
|
||||
toast.success(`技能「${result.skill_name}」已安装并加入已选`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full flex-col gap-0 p-0 sm:max-w-xl"
|
||||
>
|
||||
<SheetHeader className="border-b px-5 pb-3 pt-4">
|
||||
<SheetTitle>添加技能</SheetTitle>
|
||||
<SheetDescription>
|
||||
为该智能体启用技能。修改后需在右侧面板点击「保存」才会生效。
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Toolbar: search / source / upload */}
|
||||
<div className="space-y-2.5 border-b px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="搜索技能(中英文名称、描述)…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={source}
|
||||
onValueChange={(v) => setSource(v as SourceFilter)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-28 shrink-0 px-2 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{SOURCE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 shrink-0 text-xs"
|
||||
disabled={isUploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="上传 .zip / .skill 技能包"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<UploadIcon className="size-3.5" />
|
||||
)}
|
||||
上传技能
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip,.skill"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void handleUploadFile(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
{categories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[ALL_CATEGORIES, ...categories].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setCategory(c)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs transition-colors",
|
||||
category === c
|
||||
? "border-primary/50 bg-primary/10 text-primary font-medium"
|
||||
: "border-border text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{c === ALL_CATEGORIES ? "全部" : c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{filtered.length} 个匹配技能
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs"
|
||||
disabled={filteredUnselected.length === 0}
|
||||
onClick={addAllFiltered}
|
||||
>
|
||||
<PlusIcon className="size-3.5" />
|
||||
添加当前筛选结果
|
||||
{filteredUnselected.length > 0
|
||||
? `(${filteredUnselected.length})`
|
||||
: ""}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground h-7 text-xs"
|
||||
disabled={selected.length === 0}
|
||||
onClick={clearSelected}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
清空已选
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skill card grid */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-3">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex h-32 items-center justify-center gap-2 text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
技能加载中…
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center gap-1.5 text-sm">
|
||||
<span>没有匹配的技能</span>
|
||||
{(query || category !== ALL_CATEGORIES || source !== "all") && (
|
||||
<span className="text-xs">试试调整搜索词或筛选条件</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{filtered.map((skill) => {
|
||||
const isSelected = selectedSet.has(skill.name);
|
||||
return (
|
||||
<div
|
||||
key={skill.name}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => toggle(skill.name)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggle(skill.name);
|
||||
}
|
||||
}}
|
||||
title={isSelected ? "点击禁用" : "点击启用"}
|
||||
className={cn(
|
||||
"group/card flex cursor-pointer flex-col gap-2 rounded-xl border p-3 transition-colors",
|
||||
isSelected
|
||||
? "border-primary/50 bg-primary/5 ring-primary/15 ring-1"
|
||||
: "bg-background/60 hover:border-primary/40 hover:bg-accent/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold",
|
||||
isSelected
|
||||
? "bg-primary/15 text-primary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{skillInitial(skill)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="truncate text-sm font-medium"
|
||||
title={skillDisplayLabel(skill)}
|
||||
>
|
||||
{skillDisplayLabel(skill)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
|
||||
{skill.description?.trim()
|
||||
? desensitize(skill.description)
|
||||
: "暂无描述"}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggle(skill.name)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={isSelected ? "禁用技能" : "启用技能"}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{skill.category?.trim() && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{skill.category}
|
||||
</Badge>
|
||||
)}
|
||||
{skillSourceLabel(skill) && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{skillSourceLabel(skill)}
|
||||
</Badge>
|
||||
)}
|
||||
{isSelected && (
|
||||
<Badge className="bg-primary/15 text-primary hover:bg-primary/15 text-[10px]">
|
||||
<CheckIcon className="size-2.5" />
|
||||
已启用
|
||||
</Badge>
|
||||
)}
|
||||
{onViewDetail && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewDetail(skill.name);
|
||||
}}
|
||||
title="查看技能详情"
|
||||
className="text-muted-foreground hover:text-foreground ml-auto opacity-60 transition-opacity group-hover/card:opacity-100"
|
||||
>
|
||||
<InfoIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: selected count + done */}
|
||||
<div className="flex shrink-0 items-center justify-between border-t px-5 py-3">
|
||||
<span className="text-sm">
|
||||
已选 <span className="text-primary font-semibold tabular-nums">{selected.length}</span> 个技能
|
||||
</span>
|
||||
<Button size="sm" onClick={() => onOpenChange(false)}>
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
InboxIcon,
|
||||
InfoIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { Skill } from "@/core/skills";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
const ALL_CATEGORIES = "__all__";
|
||||
|
||||
interface SkillTransferSelectorProps {
|
||||
/** Full pool of selectable skills (caller pre-filters by `enabled`). */
|
||||
skills: Skill[];
|
||||
/** Currently selected skill names — the persisted `skills: string[]`. */
|
||||
selected: string[];
|
||||
/** Emits the next selection (always a `string[]`, `[]` when empty). */
|
||||
onChange: (next: string[]) => void;
|
||||
isLoading?: boolean;
|
||||
/** Open the skill detail dialog for a given skill name. */
|
||||
onViewDetail?: (name: string) => void;
|
||||
}
|
||||
|
||||
function skillLabel(skill: Skill) {
|
||||
return skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name);
|
||||
}
|
||||
|
||||
function matchesQuery(skill: Skill, q: string) {
|
||||
if (!q) return true;
|
||||
return (
|
||||
skill.name.toLowerCase().includes(q) ||
|
||||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
|
||||
(skill.description ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillTransferSelector({
|
||||
skills,
|
||||
selected,
|
||||
onChange,
|
||||
isLoading,
|
||||
onViewDetail,
|
||||
}: SkillTransferSelectorProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [category, setCategory] = useState<string>(ALL_CATEGORIES);
|
||||
const [availablePage, setAvailablePage] = useState(0);
|
||||
const [selectedPage, setSelectedPage] = useState(0);
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||||
|
||||
// Distinct categories for the filter dropdown (skip blank ones).
|
||||
const categories = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const skill of skills) {
|
||||
if (skill.category?.trim()) set.add(skill.category.trim());
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
}, [skills]);
|
||||
|
||||
const query = search.trim().toLowerCase();
|
||||
|
||||
// Left pane: unselected skills, filtered by search + category.
|
||||
const availableSkills = useMemo(() => {
|
||||
return skills.filter(
|
||||
(skill) =>
|
||||
!selectedSet.has(skill.name) &&
|
||||
matchesQuery(skill, query) &&
|
||||
(category === ALL_CATEGORIES || skill.category?.trim() === category),
|
||||
);
|
||||
}, [skills, selectedSet, query, category]);
|
||||
|
||||
// Right pane: selected skills, filtered by search only (category filtering
|
||||
// the chosen set would just hide active selections, which is confusing).
|
||||
const selectedSkills = useMemo(() => {
|
||||
const byName = new Map(skills.map((s) => [s.name, s]));
|
||||
return selected
|
||||
.map((name) => byName.get(name))
|
||||
.filter((s): s is Skill => Boolean(s) && matchesQuery(s!, query));
|
||||
}, [skills, selected, query]);
|
||||
|
||||
// Clamp pagination whenever the underlying lists shrink (search/category
|
||||
// changes, items moved between panes).
|
||||
const availablePages = Math.max(1, Math.ceil(availableSkills.length / PAGE_SIZE));
|
||||
const selectedPages = Math.max(1, Math.ceil(selectedSkills.length / PAGE_SIZE));
|
||||
useEffect(() => {
|
||||
if (availablePage > availablePages - 1) setAvailablePage(availablePages - 1);
|
||||
}, [availablePage, availablePages]);
|
||||
useEffect(() => {
|
||||
if (selectedPage > selectedPages - 1) setSelectedPage(selectedPages - 1);
|
||||
}, [selectedPage, selectedPages]);
|
||||
|
||||
const availableView = availableSkills.slice(
|
||||
availablePage * PAGE_SIZE,
|
||||
availablePage * PAGE_SIZE + PAGE_SIZE,
|
||||
);
|
||||
const selectedView = selectedSkills.slice(
|
||||
selectedPage * PAGE_SIZE,
|
||||
selectedPage * PAGE_SIZE + PAGE_SIZE,
|
||||
);
|
||||
|
||||
function add(name: string) {
|
||||
if (selectedSet.has(name)) return;
|
||||
onChange([...selected, name]);
|
||||
}
|
||||
function remove(name: string) {
|
||||
onChange(selected.filter((n) => n !== name));
|
||||
}
|
||||
function addAllFiltered() {
|
||||
if (availableSkills.length === 0) return;
|
||||
const merged = [...selected];
|
||||
for (const skill of availableSkills) {
|
||||
if (!selectedSet.has(skill.name)) merged.push(skill.name);
|
||||
}
|
||||
onChange(merged);
|
||||
}
|
||||
function clearSelected() {
|
||||
if (selected.length === 0) return;
|
||||
onChange([]);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-md border p-4 text-sm dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
技能加载中...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (skills.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-md border p-4 text-sm dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
当前没有可用技能。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{/* Toolbar: search + category filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="搜索全部技能(中英文名称、描述)…"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value);
|
||||
setAvailablePage(0);
|
||||
setSelectedPage(0);
|
||||
}}
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{categories.length > 0 && (
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={(v) => {
|
||||
setCategory(v);
|
||||
setAvailablePage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"border-input bg-background h-8 w-32 px-2 text-xs shadow-xs outline-none",
|
||||
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:hover:bg-[#022248]",
|
||||
"[&_svg]:dark:text-[#AACEE2]",
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="全部分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className="dark:border-[#1B477E] dark:bg-[#022248]"
|
||||
>
|
||||
<SelectItem
|
||||
value={ALL_CATEGORIES}
|
||||
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
|
||||
>
|
||||
全部分类
|
||||
</SelectItem>
|
||||
{categories.map((c) => (
|
||||
<SelectItem
|
||||
key={c}
|
||||
value={c}
|
||||
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
|
||||
>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bulk actions */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs"
|
||||
disabled={availableSkills.length === 0}
|
||||
onClick={addAllFiltered}
|
||||
>
|
||||
<PlusIcon className="size-3.5" />
|
||||
添加当前搜索结果{availableSkills.length > 0 ? `(${availableSkills.length})` : ""}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground h-7 text-xs"
|
||||
disabled={selected.length === 0}
|
||||
onClick={clearSelected}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
清空已选
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Two-column transfer panes */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<SkillPane
|
||||
title="可用技能"
|
||||
count={availableSkills.length}
|
||||
items={availableView}
|
||||
variant="available"
|
||||
page={availablePage}
|
||||
totalPages={availablePages}
|
||||
onPageChange={setAvailablePage}
|
||||
onPick={add}
|
||||
onViewDetail={onViewDetail}
|
||||
emptyHint={query || category !== ALL_CATEGORIES ? "没有匹配的技能" : "已全部启用"}
|
||||
/>
|
||||
<SkillPane
|
||||
title="已启用技能"
|
||||
count={selectedSkills.length}
|
||||
items={selectedView}
|
||||
variant="selected"
|
||||
page={selectedPage}
|
||||
totalPages={selectedPages}
|
||||
onPageChange={setSelectedPage}
|
||||
onPick={remove}
|
||||
onViewDetail={onViewDetail}
|
||||
emptyHint={query ? "没有匹配的已选技能" : "尚未启用任何技能"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkillPaneProps {
|
||||
title: string;
|
||||
count: number;
|
||||
items: Skill[];
|
||||
variant: "available" | "selected";
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPick: (name: string) => void;
|
||||
onViewDetail?: (name: string) => void;
|
||||
emptyHint: string;
|
||||
}
|
||||
|
||||
function SkillPane({
|
||||
title,
|
||||
count,
|
||||
items,
|
||||
variant,
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
onPick,
|
||||
onViewDetail,
|
||||
emptyHint,
|
||||
}: SkillPaneProps) {
|
||||
const isSelected = variant === "selected";
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-md border dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
<div className="bg-muted/40 flex items-center justify-between border-b px-2.5 py-1.5 text-xs font-medium dark:border-[#1B477E] dark:bg-[#02305f]">
|
||||
<span>{title}</span>
|
||||
<span className="text-muted-foreground tabular-nums">{count}</span>
|
||||
</div>
|
||||
<div className="h-[240px] space-y-1.5 overflow-y-auto p-2">
|
||||
{items.length === 0 ? (
|
||||
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-1.5 text-xs">
|
||||
<InboxIcon className="size-5 opacity-50" />
|
||||
<span>{emptyHint}</span>
|
||||
</div>
|
||||
) : (
|
||||
items.map((skill) => (
|
||||
<SkillItemCard
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
isSelected={isSelected}
|
||||
onPick={() => onPick(skill.name)}
|
||||
onViewDetail={
|
||||
onViewDetail ? () => onViewDetail(skill.name) : undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t px-2 py-1 text-xs dark:border-[#1B477E]">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="size-6"
|
||||
disabled={page <= 0}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
>
|
||||
<ChevronLeftIcon className="size-3.5" />
|
||||
</Button>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{page + 1}/{totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="size-6"
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
>
|
||||
<ChevronRightIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkillItemCardProps {
|
||||
skill: Skill;
|
||||
isSelected: boolean;
|
||||
onPick: () => void;
|
||||
onViewDetail?: () => void;
|
||||
}
|
||||
|
||||
function SkillItemCard({
|
||||
skill,
|
||||
isSelected,
|
||||
onPick,
|
||||
onViewDetail,
|
||||
}: SkillItemCardProps) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onPick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onPick();
|
||||
}
|
||||
}}
|
||||
title={isSelected ? "点击移除" : "点击添加"}
|
||||
className={cn(
|
||||
"group/item relative cursor-pointer rounded-md border px-2.5 py-1.5 pr-7 transition-colors",
|
||||
isSelected
|
||||
? "border-primary/60 bg-primary/5 hover:border-primary dark:border-[#3B82F6] dark:bg-[#0a3568]"
|
||||
: "hover:border-primary/40 hover:bg-accent/40 dark:border-[#213D5E] dark:bg-[#02244C] dark:hover:border-[#3B82F6]/60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-4 shrink-0 items-center justify-center rounded-full text-[10px]",
|
||||
isSelected
|
||||
? "bg-primary/15 text-primary"
|
||||
: "bg-muted text-muted-foreground group-hover/item:bg-primary/15 group-hover/item:text-primary",
|
||||
)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<XIcon className="size-3" />
|
||||
) : (
|
||||
<PlusIcon className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="truncate text-xs font-medium"
|
||||
title={skillLabel(skill)}
|
||||
>
|
||||
{skillLabel(skill)}
|
||||
</div>
|
||||
{skill.description?.trim() && (
|
||||
<div className="text-muted-foreground truncate text-[11px]">
|
||||
{desensitize(skill.description)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onViewDetail && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewDetail();
|
||||
}}
|
||||
title="查看技能详情"
|
||||
className="text-muted-foreground hover:text-foreground absolute right-1.5 top-1.5 opacity-60 transition-opacity group-hover/item:opacity-100"
|
||||
>
|
||||
<InfoIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,6 +76,7 @@ import { useThread } from "../messages/context";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
import { useArtifacts } from "./context";
|
||||
import { SpreadsheetPreview } from "./spreadsheet-preview";
|
||||
|
||||
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
|
||||
|
||||
@@ -137,6 +138,11 @@ export function ArtifactFileDetail({
|
||||
const isSkillFile = useMemo(() => {
|
||||
return filepath.endsWith(".skill");
|
||||
}, [filepath]);
|
||||
// .xlsx is a ZIP archive — the generic binary iframe preview renders its
|
||||
// raw bytes as mojibake, so it gets a dedicated table preview instead.
|
||||
const isSpreadsheetFile = useMemo(() => {
|
||||
return /\.xlsx$/i.test(filepath);
|
||||
}, [filepath]);
|
||||
const { isCodeFile, language } = useMemo(() => {
|
||||
if (useWriteFileStreaming) {
|
||||
let nextLanguage = checkCodeFile(filepath).language;
|
||||
@@ -174,13 +180,14 @@ export function ArtifactFileDetail({
|
||||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||
const fileTypeLabel = useMemo(() => {
|
||||
if (isImageFile(filepath)) return "IMG";
|
||||
if (isSpreadsheetFile) return "XLSX";
|
||||
if (isMermaidFile(filepath) || language === "mermaid") return "MMD";
|
||||
if (language === "html") return "HTML";
|
||||
if (language === "markdown") return "MD";
|
||||
if (isSkillFile) return "SKILL";
|
||||
if (language) return language.toUpperCase();
|
||||
return "FILE";
|
||||
}, [filepath, isSkillFile, language]);
|
||||
}, [filepath, isSkillFile, isSpreadsheetFile, language]);
|
||||
|
||||
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
||||
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
|
||||
@@ -191,6 +198,9 @@ export function ArtifactFileDetail({
|
||||
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [spreadsheetData, setSpreadsheetData] = useState<ArrayBuffer | null>(
|
||||
null,
|
||||
);
|
||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
@@ -310,6 +320,7 @@ export function ArtifactFileDetail({
|
||||
|
||||
useEffect(() => {
|
||||
if (isCodeFile) {
|
||||
setSpreadsheetData(null);
|
||||
setBinaryPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
@@ -325,6 +336,13 @@ export function ArtifactFileDetail({
|
||||
urlOfArtifact({ filepath, threadId, isMock, publicShareCode }),
|
||||
);
|
||||
if (cancelled || !res.ok) return;
|
||||
if (isSpreadsheetFile) {
|
||||
const buffer = await res.arrayBuffer();
|
||||
if (!cancelled) {
|
||||
setSpreadsheetData(buffer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
if (cancelled) return;
|
||||
const u = URL.createObjectURL(blob);
|
||||
@@ -342,12 +360,13 @@ export function ArtifactFileDetail({
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
setSpreadsheetData(null);
|
||||
setBinaryPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
};
|
||||
}, [filepath, threadId, isMock, isCodeFile, publicShareCode]);
|
||||
}, [filepath, threadId, isMock, isCodeFile, isSpreadsheetFile, publicShareCode]);
|
||||
|
||||
const handlePauseAutoScroll = useCallback(() => {
|
||||
if (!thread.isLoading || !autoScrollEnabled) {
|
||||
@@ -766,7 +785,18 @@ export function ArtifactFileDetail({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isCodeFile && isSpreadsheetFile && (
|
||||
spreadsheetData ? (
|
||||
<SpreadsheetPreview data={spreadsheetData} />
|
||||
) : (
|
||||
<div className="text-muted-foreground flex size-full items-center justify-center gap-2 text-sm">
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{!isCodeFile &&
|
||||
!isSpreadsheetFile &&
|
||||
(binaryPreviewUrl ? (
|
||||
<iframe className="size-full bg-white" src={binaryPreviewUrl} />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { parseXlsx, type ParsedWorksheet } from "@/core/utils/xlsx";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Renders an .xlsx ArrayBuffer as sheet tabs + an HTML table, replacing the
|
||||
* old binary-blob iframe preview that showed the raw ZIP bytes as mojibake.
|
||||
*/
|
||||
export function SpreadsheetPreview({
|
||||
data,
|
||||
className,
|
||||
}: {
|
||||
data: ArrayBuffer;
|
||||
className?: string;
|
||||
}) {
|
||||
const [sheets, setSheets] = useState<ParsedWorksheet[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeSheetIndex, setActiveSheetIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSheets(null);
|
||||
setError(null);
|
||||
setActiveSheetIndex(0);
|
||||
parseXlsx(data)
|
||||
.then((parsed) => {
|
||||
if (!cancelled) {
|
||||
setSheets(parsed);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("xlsx preview parse failed:", err);
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "解析失败");
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground flex size-full flex-col items-center justify-center gap-2 p-6 text-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<TriangleAlertIcon className="size-5" />
|
||||
<div>无法预览此表格文件:{error}</div>
|
||||
<div className="text-xs">可点击右上角下载后用本地软件打开。</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!sheets) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground flex size-full items-center justify-center gap-2 text-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
正在解析表格…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const activeSheet = sheets[activeSheetIndex] ?? sheets[0]!;
|
||||
const [headerRow, ...bodyRows] = activeSheet.rows;
|
||||
const columnCount = Math.max(
|
||||
headerRow?.length ?? 0,
|
||||
...activeSheet.rows.map((row) => row.length),
|
||||
1,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-artifact-preview-scroll
|
||||
className={cn("flex size-full flex-col overflow-hidden", className)}
|
||||
>
|
||||
{sheets.length > 1 && (
|
||||
<div className="border-border/70 flex shrink-0 items-center gap-1 overflow-x-auto border-b px-3 py-2">
|
||||
{sheets.map((sheet, index) => (
|
||||
<button
|
||||
key={`${sheet.name}-${index}`}
|
||||
type="button"
|
||||
onClick={() => setActiveSheetIndex(index)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-2.5 py-1 text-xs transition-colors",
|
||||
index === activeSheetIndex
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{desensitize(sheet.name)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||
{activeSheet.rows.length === 0 ? (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
|
||||
(空工作表)
|
||||
</div>
|
||||
) : (
|
||||
<table className="border-border w-max min-w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
{Array.from({ length: columnCount }, (_, columnIndex) => (
|
||||
<th
|
||||
key={columnIndex}
|
||||
className="border-border bg-muted/60 text-foreground sticky top-0 border px-3 py-1.5 text-left font-medium whitespace-pre-wrap"
|
||||
>
|
||||
{desensitize(headerRow?.[columnIndex] ?? "")}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bodyRows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="even:bg-muted/30">
|
||||
{Array.from({ length: columnCount }, (_, columnIndex) => (
|
||||
<td
|
||||
key={columnIndex}
|
||||
className="border-border border px-3 py-1.5 align-top whitespace-pre-wrap"
|
||||
>
|
||||
{desensitize(row[columnIndex] ?? "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{activeSheet.truncated && (
|
||||
<div className="text-muted-foreground py-2 text-center text-xs">
|
||||
表格过大,仅预览部分内容;完整数据请下载文件查看。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
ArtifactFileList,
|
||||
useArtifacts,
|
||||
} from "../artifacts";
|
||||
import { useThread } from "../messages/context";
|
||||
import { ReferenceBatchesContext, useThread } from "../messages/context";
|
||||
import { SkillDisplaySources } from "../skill-display-sources";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
@@ -362,10 +362,15 @@ const ChatBox: React.FC<{
|
||||
}, [sidePanelOpen]);
|
||||
|
||||
if (disableArtifacts) {
|
||||
return <>{children}</>;
|
||||
return (
|
||||
<ReferenceBatchesContext.Provider value={referenceBatches}>
|
||||
{children}
|
||||
</ReferenceBatchesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReferenceBatchesContext.Provider value={referenceBatches}>
|
||||
<ResizablePanelGroup
|
||||
id={`${resizableIdBase}-panels`}
|
||||
orientation="horizontal"
|
||||
@@ -490,6 +495,7 @@ const ChatBox: React.FC<{
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ReferenceBatchesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -86,8 +86,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import { useUserPreferences } from "@/core/user-preferences";
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import { useUserPreferences } from "@/core/user-preferences";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
@@ -133,6 +133,7 @@ export function InputBox({
|
||||
promptPrefix,
|
||||
agentSelector,
|
||||
assistantId,
|
||||
hideAdminRecommendedQuestions,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||
assistantId?: string | null;
|
||||
@@ -149,6 +150,9 @@ export function InputBox({
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
isNewThread?: boolean;
|
||||
/** Suppress the global admin "recommended questions" row. The agent chat page
|
||||
* sets this so only the agent's own common questions surface on its home. */
|
||||
hideAdminRecommendedQuestions?: boolean;
|
||||
threadId: string;
|
||||
initialValue?: string;
|
||||
onContextChange?: (
|
||||
@@ -205,11 +209,12 @@ export function InputBox({
|
||||
null,
|
||||
);
|
||||
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
const userPreferences = useUserPreferences();
|
||||
const showAdminRecommendedQuestions =
|
||||
!userPreferences.hide_admin_prompt_prefix &&
|
||||
recommendedQuestions.length > 0;
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
const userPreferences = useUserPreferences();
|
||||
const showAdminRecommendedQuestions =
|
||||
!hideAdminRecommendedQuestions &&
|
||||
!userPreferences.hide_admin_prompt_prefix &&
|
||||
recommendedQuestions.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (models.length === 0) {
|
||||
@@ -516,7 +521,7 @@ export function InputBox({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||
{recommendedQuestions.map((q) => (
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
|
||||
export interface ThreadContextType {
|
||||
@@ -26,3 +27,18 @@ export function useThread() {
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* One turn's reference sources, keyed to the assistant message that answered
|
||||
* the turn. Provided by ChatBox so inline citation rendering and the
|
||||
* right-side reference panel always resolve `[n]` against the SAME sources
|
||||
* (the panel prefers backend reference batches, which the per-message
|
||||
* frontend collection in MessageList knows nothing about).
|
||||
*/
|
||||
export interface ReferenceBatch {
|
||||
id: string;
|
||||
assistant_message_id?: string;
|
||||
sources: RuntimeDisplaySource[];
|
||||
}
|
||||
|
||||
export const ReferenceBatchesContext = createContext<ReferenceBatch[]>([]);
|
||||
@@ -37,7 +37,7 @@ import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||
import { StreamingIndicator } from "../streaming-indicator";
|
||||
|
||||
import { ThreadContext } from "./context";
|
||||
import { ReferenceBatchesContext, ThreadContext } from "./context";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { MessageGroup } from "./message-group";
|
||||
import { MessageListItem } from "./message-list-item";
|
||||
@@ -146,6 +146,11 @@ export function MessageList({
|
||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[ragModeEnabled, skills],
|
||||
);
|
||||
// Same per-turn reference batches the right-side panel renders (backend
|
||||
// batches preferred). Inline [n] tokens must resolve against these so badge
|
||||
// numbering matches the panel, and so citations still render when the
|
||||
// frontend tool-message scan below comes up empty (e.g. after a reload).
|
||||
const referenceBatches = useContext(ReferenceBatchesContext);
|
||||
|
||||
// Track which clarification messages are still awaiting an answer.
|
||||
// Pending = clarification tool messages that appear after the last human message
|
||||
@@ -270,16 +275,24 @@ export function MessageList({
|
||||
{groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return group.messages.map((msg) => {
|
||||
const displaySources =
|
||||
const isAssistantMessage =
|
||||
ragModeEnabled &&
|
||||
group.type === "assistant" &&
|
||||
msg.type === "ai"
|
||||
? collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
msg.type === "ai";
|
||||
const batchSources = isAssistantMessage
|
||||
? referenceBatches.find(
|
||||
(batch) => batch.assistant_message_id === msg.id,
|
||||
)?.sources ?? []
|
||||
: [];
|
||||
const displaySources = isAssistantMessage
|
||||
? batchSources.length > 0
|
||||
? batchSources
|
||||
: collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
||||
beforeMessageId: msg.id,
|
||||
includeBeforeMessage: true,
|
||||
})
|
||||
: [];
|
||||
: [];
|
||||
return (
|
||||
<MessageListItem
|
||||
key={`${group.id}/${msg.id}`}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
|
||||
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
|
||||
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
|
||||
import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage";
|
||||
|
||||
@@ -349,6 +350,8 @@ export function AppearanceSettingsPage() {
|
||||
<PromptPrefixSettingsPage embedded />
|
||||
<Separator />
|
||||
<PrivateSquareSettingsPage embedded />
|
||||
<Separator />
|
||||
<PublishSquaresSettingsPage embedded />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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";
|
||||
@@ -81,7 +82,7 @@ import {
|
||||
useSkills,
|
||||
useValidateSkillUpload,
|
||||
} from "@/core/skills/hooks";
|
||||
import { useViewMode } from "@/core/system-settings/hooks";
|
||||
import { usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
@@ -164,6 +165,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
null,
|
||||
);
|
||||
const [viewMode, setViewMode] = useViewMode("skill");
|
||||
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).
|
||||
@@ -214,14 +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(
|
||||
() =>
|
||||
publisher
|
||||
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
|
||||
: scopedSkills,
|
||||
[scopedSkills, publisher],
|
||||
);
|
||||
const filteredSkills = useMemo(() => {
|
||||
let list = publisher
|
||||
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
|
||||
: scopedSkills;
|
||||
// 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 === filter || (isDefault && !sid);
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}, [scopedSkills, publisher, filter, isSquareTab, publishSquares?.default_square_id]);
|
||||
const orderedSkills = useMemo(
|
||||
() =>
|
||||
filteredSkills
|
||||
@@ -247,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],
|
||||
@@ -304,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
|
||||
@@ -544,6 +576,7 @@ export function SkillRow({
|
||||
const isCard = layout === "card";
|
||||
const { t } = useI18n();
|
||||
const setPublished = useSetSkillPublished();
|
||||
const { data: cardPublishSquares } = usePublishSquares();
|
||||
const setPinned = useSetSkillPinned();
|
||||
const setFeatured = useSetSkillFeatured();
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
@@ -553,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
|
||||
@@ -566,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(
|
||||
@@ -578,6 +618,24 @@ export function SkillRow({
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteSkill.mutateAsync(skill.name);
|
||||
@@ -979,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="请选择要把该技能发布到哪个广场。每个技能只能归属一个广场,不会重复出现。"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ export function WorkspaceNavChatList({
|
||||
>
|
||||
<Link href="/page/workspace/knowledge">
|
||||
<BookOpen />
|
||||
<span>知识库</span>
|
||||
<span>问答梳理</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
Reference in new issue
Block a user