feat: update knowledge and agent UI
This commit is contained in:
1 parent
bebe4feccf
commit
7efd811e75
27 files changed
+2949
-782
No files matched your search
@@ -20,6 +20,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
applyCircularLayout,
|
applyCircularLayout,
|
||||||
ForceSimulation,
|
ForceSimulation,
|
||||||
|
getEdgeId,
|
||||||
getEdgeInfo,
|
getEdgeInfo,
|
||||||
getEdgeLabel,
|
getEdgeLabel,
|
||||||
getLinkDistance,
|
getLinkDistance,
|
||||||
@@ -56,8 +57,20 @@ interface GraphCanvasProps {
|
|||||||
onSelectNode: (id: string | null) => void;
|
onSelectNode: (id: string | null) => void;
|
||||||
onTooltip: (tooltip: TooltipState | null) => void;
|
onTooltip: (tooltip: TooltipState | null) => void;
|
||||||
onZoomChange: (k: number) => 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. */
|
/** Floating pills rendered top-left over the canvas. */
|
||||||
toolbar?: ReactNode;
|
toolbar?: ReactNode;
|
||||||
|
/** Small floating hint rendered bottom-center over the canvas. */
|
||||||
|
hint?: ReactNode;
|
||||||
/** Centered overlay (empty / loading states). */
|
/** Centered overlay (empty / loading states). */
|
||||||
overlay?: ReactNode;
|
overlay?: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -153,7 +166,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
if (!source || !target) continue;
|
if (!source || !target) continue;
|
||||||
const label = getEdgeLabel(e);
|
const label = getEdgeLabel(e);
|
||||||
simEdges.push({
|
simEdges.push({
|
||||||
id: e.id ?? `${e.source}->${e.target}:${label}`,
|
id: getEdgeId(e),
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
label,
|
label,
|
||||||
@@ -213,7 +226,13 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
// Redraw on highlight-affecting prop changes.
|
// Redraw on highlight-affecting prop changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
requestDraw();
|
requestDraw();
|
||||||
}, [props.selectedNodeId, props.searchMatchIds, props.showLabels, requestDraw]);
|
}, [
|
||||||
|
props.selectedNodeId,
|
||||||
|
props.searchMatchIds,
|
||||||
|
props.showLabels,
|
||||||
|
props.explore,
|
||||||
|
requestDraw,
|
||||||
|
]);
|
||||||
|
|
||||||
// ------------------------------------------------------------ transform
|
// ------------------------------------------------------------ transform
|
||||||
|
|
||||||
@@ -361,6 +380,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
const neighbors = activeId ? adjacencyRef.current.get(activeId) : undefined;
|
const neighbors = activeId ? adjacencyRef.current.get(activeId) : undefined;
|
||||||
const search = p.searchMatchIds;
|
const search = p.searchMatchIds;
|
||||||
const k = t.k || 1;
|
const k = t.k || 1;
|
||||||
|
const explore = p.explore;
|
||||||
|
|
||||||
// ---- edges
|
// ---- edges
|
||||||
for (const e of sim.edges) {
|
for (const e of sim.edges) {
|
||||||
@@ -373,6 +393,13 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
alpha = search.has(e.source.id) || search.has(e.target.id) ? 0.75 : 0.06;
|
alpha = search.has(e.source.id) || search.has(e.target.id) ? 0.75 : 0.06;
|
||||||
else alpha = 0.38;
|
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.globalAlpha = alpha;
|
||||||
ctx.strokeStyle = e.info.color;
|
ctx.strokeStyle = e.info.color;
|
||||||
ctx.lineWidth = (touchesActive ? 2.4 : 1.2) / k;
|
ctx.lineWidth = (touchesActive ? 2.4 : 1.2) / k;
|
||||||
@@ -405,33 +432,73 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
const isActive = n.id === activeId;
|
const isActive = n.id === activeId;
|
||||||
const isSelected = n.id === p.selectedNodeId;
|
const isSelected = n.id === p.selectedNodeId;
|
||||||
const isNeighbor = neighbors?.has(n.id) ?? false;
|
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;
|
let alpha: number;
|
||||||
if (activeId) alpha = isActive || isNeighbor ? 1 : 0.14;
|
if (activeId) alpha = isActive || isNeighbor ? 1 : 0.14;
|
||||||
else if (search) alpha = search.has(n.id) ? 1 : 0.14;
|
else if (search) alpha = search.has(n.id) ? 1 : 0.14;
|
||||||
else alpha = 1;
|
else alpha = 1;
|
||||||
|
|
||||||
const r = n.radius * (n.id === hover?.id ? 1.15 : 1);
|
let baseRadius = n.radius;
|
||||||
|
let labelByExplore = false;
|
||||||
|
let strokeWidth = 1.4 / k;
|
||||||
|
let strokeColor = "rgba(2, 6, 23, 0.55)";
|
||||||
|
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 ? "#e0f2fe" : isExpanded ? "#7dd3fc" : "rgba(2, 6, 23, 0.55)";
|
||||||
|
if (isExploreRoot) fillColor = "#38bdf8";
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = baseRadius * (n.id === hover?.id ? 1.15 : 1);
|
||||||
ctx.globalAlpha = alpha;
|
ctx.globalAlpha = alpha;
|
||||||
|
|
||||||
// Glow for hubs and the active node.
|
// Glow for hubs and the active node.
|
||||||
if (alpha > 0.5 && (isActive || isSelected || n.degree >= labelThreshold)) {
|
if (
|
||||||
ctx.shadowColor = n.color;
|
alpha > 0.5 &&
|
||||||
ctx.shadowBlur = isActive || isSelected ? 22 : 12;
|
(isActive || isSelected || isExploreRoot || isExpanded || n.degree >= labelThreshold)
|
||||||
|
) {
|
||||||
|
ctx.shadowColor = fillColor;
|
||||||
|
ctx.shadowBlur = isActive || isSelected || isExploreRoot ? 24 : 12;
|
||||||
} else {
|
} else {
|
||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = n.color;
|
ctx.fillStyle = fillColor;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
// Rim.
|
// Rim.
|
||||||
ctx.lineWidth = 1.4 / k;
|
ctx.lineWidth = strokeWidth;
|
||||||
ctx.strokeStyle = "rgba(2, 6, 23, 0.55)";
|
ctx.strokeStyle = strokeColor;
|
||||||
ctx.stroke();
|
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 = "rgba(125, 211, 252, 0.9)";
|
||||||
|
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 = "#7dd3fc";
|
||||||
|
ctx.fill();
|
||||||
|
ctx.lineWidth = 1 / k;
|
||||||
|
ctx.strokeStyle = "rgba(2, 6, 23, 0.8)";
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
// Selection: double ring.
|
// Selection: double ring.
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -465,6 +532,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
isActive ||
|
isActive ||
|
||||||
isSelected ||
|
isSelected ||
|
||||||
isNeighbor ||
|
isNeighbor ||
|
||||||
|
labelByExplore ||
|
||||||
(search?.has(n.id) ?? false) ||
|
(search?.has(n.id) ?? false) ||
|
||||||
(p.showLabels && n.degree >= labelThreshold && k >= 0.45);
|
(p.showLabels && n.degree >= labelThreshold && k >= 0.45);
|
||||||
if (wantsLabel && alpha > 0.5) {
|
if (wantsLabel && alpha > 0.5) {
|
||||||
@@ -775,6 +843,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
{props.toolbar ? (
|
{props.toolbar ? (
|
||||||
<div className="kg-canvas-toolbar">{props.toolbar}</div>
|
<div className="kg-canvas-toolbar">{props.toolbar}</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{props.hint}
|
||||||
{minimapVisible ? (
|
{minimapVisible ? (
|
||||||
<canvas ref={minimapRef} className="kg-minimap" width={180} height={120} />
|
<canvas ref={minimapRef} className="kg-minimap" width={180} height={120} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { Crosshair, EyeOff, FileText, Loader2, MousePointerClick } from "lucide-react";
|
import {
|
||||||
|
Compass,
|
||||||
|
Crosshair,
|
||||||
|
EyeOff,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
MousePointerClick,
|
||||||
|
Waypoints,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import type { KnowledgeNote } from "@/core/knowledge/types";
|
import type { KnowledgeNote } from "@/core/knowledge/types";
|
||||||
|
|
||||||
@@ -37,10 +46,20 @@ interface GraphDetailPanelProps {
|
|||||||
/** Note detail (only for note-type nodes; undefined while absent/loading). */
|
/** Note detail (only for note-type nodes; undefined while absent/loading). */
|
||||||
note: KnowledgeNote | undefined;
|
note: KnowledgeNote | undefined;
|
||||||
noteLoading: boolean;
|
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;
|
onViewSource: (noteId: string) => void;
|
||||||
onLocate: (id: string) => void;
|
onLocate: (id: string) => void;
|
||||||
onHide: (id: string) => void;
|
onHide: (id: string) => void;
|
||||||
onPickNeighbor: (id: string) => void;
|
onPickNeighbor: (id: string) => void;
|
||||||
|
onExpandNode: (id: string) => void;
|
||||||
|
onSetExploreRoot: (id: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(value: string | null | undefined): string {
|
function formatTime(value: string | null | undefined): string {
|
||||||
@@ -57,6 +76,14 @@ export function GraphDetailPanel(props: GraphDetailPanelProps) {
|
|||||||
<aside className="kg-panel kg-detail">
|
<aside className="kg-panel kg-detail">
|
||||||
<div className="kg-panel-header">
|
<div className="kg-panel-header">
|
||||||
<span className="kg-panel-title">节点详情</span>
|
<span className="kg-panel-title">节点详情</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="kg-panel-close-btn"
|
||||||
|
title="收起详情面板"
|
||||||
|
onClick={props.onClose}
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{!node ? (
|
{!node ? (
|
||||||
<div className="kg-detail-empty">
|
<div className="kg-detail-empty">
|
||||||
@@ -104,6 +131,28 @@ function DetailBody(props: GraphDetailPanelProps & { node: GraphNode }) {
|
|||||||
>
|
>
|
||||||
度数 {props.degree}
|
度数 {props.degree}
|
||||||
</span>
|
</span>
|
||||||
|
{props.explore && props.explore.depth !== null ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="kg-detail-type"
|
||||||
|
style={{ background: "rgba(56, 189, 248, 0.14)", color: "#7dd3fc" }}
|
||||||
|
>
|
||||||
|
{props.explore.isRoot
|
||||||
|
? "探索起点"
|
||||||
|
: `第 ${props.explore.depth} 层`}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="kg-detail-type"
|
||||||
|
style={
|
||||||
|
props.explore.expanded
|
||||||
|
? { background: "rgba(52, 211, 153, 0.14)", color: "#6ee7b7" }
|
||||||
|
: { background: "rgba(251, 191, 36, 0.14)", color: "#fcd34d" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.explore.expanded ? "已展开" : "可展开"}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{props.noteLoading ? (
|
{props.noteLoading ? (
|
||||||
@@ -171,6 +220,31 @@ function DetailBody(props: GraphDetailPanelProps & { node: GraphNode }) {
|
|||||||
>
|
>
|
||||||
<EyeOff size={14} /> 隐藏节点
|
<EyeOff size={14} /> 隐藏节点
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<h3 className="kg-detail-section-title">
|
<h3 className="kg-detail-section-title">
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Loader2,
|
Loader2,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
PanelLeft,
|
|
||||||
PanelRight,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Scan,
|
Scan,
|
||||||
Shuffle,
|
Shuffle,
|
||||||
@@ -18,8 +16,6 @@ interface GraphHeaderProps {
|
|||||||
totalNodes: number;
|
totalNodes: number;
|
||||||
totalEdges: number;
|
totalEdges: number;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
showSidebar: boolean;
|
|
||||||
showDetail: boolean;
|
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onRelayout: () => void;
|
onRelayout: () => void;
|
||||||
onFitView: () => void;
|
onFitView: () => void;
|
||||||
@@ -28,8 +24,6 @@ interface GraphHeaderProps {
|
|||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
onFullscreen: () => void;
|
onFullscreen: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onToggleSidebar: () => void;
|
|
||||||
onToggleDetail: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GraphHeader(props: GraphHeaderProps) {
|
export function GraphHeader(props: GraphHeaderProps) {
|
||||||
@@ -124,23 +118,6 @@ export function GraphHeader(props: GraphHeaderProps) {
|
|||||||
<RefreshCw size={15} />
|
<RefreshCw size={15} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className="kg-header-divider" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`kg-icon-button${props.showSidebar ? " kg-icon-button-active" : ""}`}
|
|
||||||
title="筛选面板"
|
|
||||||
onClick={props.onToggleSidebar}
|
|
||||||
>
|
|
||||||
<PanelLeft size={15} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`kg-icon-button${props.showDetail ? " kg-icon-button-active" : ""}`}
|
|
||||||
title="详情面板"
|
|
||||||
onClick={props.onToggleDetail}
|
|
||||||
>
|
|
||||||
<PanelRight size={15} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Search } from "lucide-react";
|
import { ChevronDown, ChevronRight, Search, X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import type { GraphNode, GraphStats, NodeKind } from "./graphTypes";
|
import type { GraphNode, GraphStats, NodeKind } from "./graphTypes";
|
||||||
import {
|
import {
|
||||||
@@ -24,9 +25,56 @@ interface GraphSidebarProps {
|
|||||||
onToggleEdgeLabel: (label: string) => void;
|
onToggleEdgeLabel: (label: string) => void;
|
||||||
onRestoreHidden: () => void;
|
onRestoreHidden: () => void;
|
||||||
onPickNode: (id: string) => void;
|
onPickNode: (id: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SectionKey = "nodeTypes" | "edgeTypes" | "graphStats" | "coreNodes";
|
||||||
|
|
||||||
|
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) {
|
export function GraphSidebar(props: GraphSidebarProps) {
|
||||||
|
// 节点类型 / 关系类型默认收起,统计与核心节点默认展开。
|
||||||
|
const [collapsedSections, setCollapsedSections] = useState<
|
||||||
|
Record<SectionKey, boolean>
|
||||||
|
>({
|
||||||
|
nodeTypes: true,
|
||||||
|
edgeTypes: true,
|
||||||
|
graphStats: false,
|
||||||
|
coreNodes: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleFilterSection = (key: SectionKey) => {
|
||||||
|
setCollapsedSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
};
|
||||||
|
|
||||||
const kinds = NODE_KIND_ORDER.filter((k) => (props.nodeKindCounts.get(k) ?? 0) > 0);
|
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]);
|
const edgeLabels = [...props.edgeLabelCounts.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
|
||||||
@@ -34,15 +82,25 @@ export function GraphSidebar(props: GraphSidebarProps) {
|
|||||||
<aside className="kg-panel kg-sidebar">
|
<aside className="kg-panel kg-sidebar">
|
||||||
<div className="kg-panel-header">
|
<div className="kg-panel-header">
|
||||||
<span className="kg-panel-title">搜索与筛选</span>
|
<span className="kg-panel-title">搜索与筛选</span>
|
||||||
{props.hiddenCount > 0 ? (
|
<div className="kg-panel-header-actions">
|
||||||
|
{props.hiddenCount > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="kg-text-button"
|
||||||
|
onClick={props.onRestoreHidden}
|
||||||
|
>
|
||||||
|
恢复 {props.hiddenCount} 个隐藏节点
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="kg-text-button"
|
className="kg-panel-close-btn"
|
||||||
onClick={props.onRestoreHidden}
|
title="收起筛选面板"
|
||||||
|
onClick={props.onClose}
|
||||||
>
|
>
|
||||||
恢复 {props.hiddenCount} 个隐藏节点
|
<X size={15} />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="kg-panel-content">
|
<div className="kg-panel-content">
|
||||||
<div className="kg-search-wrap">
|
<div className="kg-search-wrap">
|
||||||
@@ -67,8 +125,12 @@ export function GraphSidebar(props: GraphSidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="kg-filter-section">
|
<FilterSection
|
||||||
<div className="kg-filter-title">节点类型</div>
|
title="节点类型"
|
||||||
|
extra={`${kinds.length} 类`}
|
||||||
|
collapsed={collapsedSections.nodeTypes}
|
||||||
|
onToggle={() => toggleFilterSection("nodeTypes")}
|
||||||
|
>
|
||||||
{kinds.map((kind) => (
|
{kinds.map((kind) => (
|
||||||
<label key={kind} className="kg-check-row">
|
<label key={kind} className="kg-check-row">
|
||||||
<span className="kg-check-row-main">
|
<span className="kg-check-row-main">
|
||||||
@@ -86,11 +148,15 @@ export function GraphSidebar(props: GraphSidebarProps) {
|
|||||||
<span className="kg-count">{props.nodeKindCounts.get(kind)}</span>
|
<span className="kg-count">{props.nodeKindCounts.get(kind)}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</section>
|
</FilterSection>
|
||||||
|
|
||||||
{edgeLabels.length > 0 ? (
|
{edgeLabels.length > 0 ? (
|
||||||
<section className="kg-filter-section">
|
<FilterSection
|
||||||
<div className="kg-filter-title">关系类型</div>
|
title="关系类型"
|
||||||
|
extra={`${edgeLabels.length} 类`}
|
||||||
|
collapsed={collapsedSections.edgeTypes}
|
||||||
|
onToggle={() => toggleFilterSection("edgeTypes")}
|
||||||
|
>
|
||||||
{edgeLabels.map(([label, count]) => {
|
{edgeLabels.map(([label, count]) => {
|
||||||
const info = getEdgeInfo(label);
|
const info = getEdgeInfo(label);
|
||||||
return (
|
return (
|
||||||
@@ -114,11 +180,14 @@ export function GraphSidebar(props: GraphSidebarProps) {
|
|||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</section>
|
</FilterSection>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="kg-filter-section">
|
<FilterSection
|
||||||
<div className="kg-filter-title">图谱统计</div>
|
title="图谱统计"
|
||||||
|
collapsed={collapsedSections.graphStats}
|
||||||
|
onToggle={() => toggleFilterSection("graphStats")}
|
||||||
|
>
|
||||||
<div className="kg-stats-grid">
|
<div className="kg-stats-grid">
|
||||||
<div className="kg-stat-card">
|
<div className="kg-stat-card">
|
||||||
<div className="kg-stat-card-value">{props.stats.nodes}</div>
|
<div className="kg-stat-card-value">{props.stats.nodes}</div>
|
||||||
@@ -139,11 +208,15 @@ export function GraphSidebar(props: GraphSidebarProps) {
|
|||||||
<div className="kg-stat-card-label">最大连通分量</div>
|
<div className="kg-stat-card-label">最大连通分量</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</FilterSection>
|
||||||
|
|
||||||
{props.topNodes.length > 0 ? (
|
{props.topNodes.length > 0 ? (
|
||||||
<section className="kg-filter-section">
|
<FilterSection
|
||||||
<div className="kg-filter-title">核心节点</div>
|
title="核心节点"
|
||||||
|
extra={`${props.topNodes.length} 个`}
|
||||||
|
collapsed={collapsedSections.coreNodes}
|
||||||
|
onToggle={() => toggleFilterSection("coreNodes")}
|
||||||
|
>
|
||||||
{props.topNodes.map(({ node, degree }, i) => (
|
{props.topNodes.map(({ node, degree }, i) => (
|
||||||
<div
|
<div
|
||||||
key={node.id}
|
key={node.id}
|
||||||
@@ -155,7 +228,7 @@ export function GraphSidebar(props: GraphSidebarProps) {
|
|||||||
<span className="kg-rank-degree">{degree} 度</span>
|
<span className="kg-rank-degree">{degree} 度</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</FilterSection>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -205,12 +205,39 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.kg-panel-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.kg-panel-title {
|
.kg-panel-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--kg-text-main);
|
color: var(--kg-text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.kg-panel-close-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
background: rgba(15, 23, 42, 0.65);
|
||||||
|
color: #94a3b8;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-panel-close-btn:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(56, 189, 248, 0.14);
|
||||||
|
border-color: rgba(56, 189, 248, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
.kg-panel-content {
|
.kg-panel-content {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -267,15 +294,44 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.kg-filter-section {
|
.kg-filter-section {
|
||||||
margin-top: 18px;
|
border-top: 1px solid rgba(148, 163, 184, 0.1);
|
||||||
|
padding: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kg-filter-title {
|
.kg-filter-section:first-of-type {
|
||||||
font-size: 12px;
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-filter-section-header {
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #cbd5e1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--kg-text-secondary);
|
}
|
||||||
margin-bottom: 8px;
|
|
||||||
letter-spacing: 0.02em;
|
.kg-filter-section-header:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-filter-section-extra {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-filter-section-body {
|
||||||
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kg-check-row {
|
.kg-check-row {
|
||||||
@@ -421,6 +477,14 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-canvas-shell {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kg-canvas {
|
.kg-canvas {
|
||||||
@@ -485,6 +549,82 @@
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.kg-explore-button {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
background: rgba(15, 23, 42, 0.72);
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-explore-button:hover {
|
||||||
|
background: rgba(56, 189, 248, 0.14);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-explore-button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-canvas-hint {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 22px;
|
||||||
|
z-index: 11;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: min(560px, calc(100% - 48px));
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.22);
|
||||||
|
background: rgba(15, 23, 42, 0.78);
|
||||||
|
color: #bae6fd;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-side-rail-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
z-index: 20;
|
||||||
|
width: 32px;
|
||||||
|
min-height: 88px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.28);
|
||||||
|
background: rgba(15, 23, 42, 0.86);
|
||||||
|
color: #bae6fd;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-side-rail-toggle-left {
|
||||||
|
left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-side-rail-toggle-right {
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-side-rail-toggle:hover {
|
||||||
|
background: rgba(56, 189, 248, 0.16);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
.kg-minimap {
|
.kg-minimap {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 14px;
|
right: 14px;
|
||||||
|
|||||||
@@ -43,6 +43,18 @@ export type NodeKind =
|
|||||||
|
|
||||||
export type LayoutType = "force" | "circular";
|
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. */
|
/** Style metadata of an edge label. */
|
||||||
export interface EdgeInfo {
|
export interface EdgeInfo {
|
||||||
color: string;
|
color: string;
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ export function truncateLabel(text: string, max = 14): string {
|
|||||||
export const getEdgeLabel = (edge: GraphEdge): string =>
|
export const getEdgeLabel = (edge: GraphEdge): string =>
|
||||||
edge.label || edge.type || edge.relation || "related";
|
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> = {
|
const KNOWN_EDGE_INFO: Record<string, EdgeInfo> = {
|
||||||
related: { color: "#94a3b8", zh: "相关", dashed: false },
|
related: { color: "#94a3b8", zh: "相关", dashed: false },
|
||||||
links: { color: "#a78bfa", zh: "引用", dashed: true },
|
links: { color: "#a78bfa", zh: "引用", dashed: true },
|
||||||
|
|||||||
@@ -1,23 +1,37 @@
|
|||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
|
|
||||||
/** Sticky breadcrumb above the reading area: folders + current document title. */
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** Inline, de-emphasized breadcrumb: folder path + current document title. */
|
||||||
export function Breadcrumb({
|
export function Breadcrumb({
|
||||||
segments,
|
segments,
|
||||||
current,
|
current,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
segments: string[];
|
segments: string[];
|
||||||
current: string;
|
current: string;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-11 shrink-0 items-center gap-1.5 overflow-hidden border-b border-slate-50 bg-white px-5 text-[13px] text-slate-400 sm:px-8 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-500">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 overflow-hidden text-xs text-slate-400 dark:text-slate-500",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{segments.map((segment, i) => (
|
{segments.map((segment, i) => (
|
||||||
<Fragment key={`${segment}-${i}`}>
|
<Fragment key={`${segment}-${i}`}>
|
||||||
<span className="shrink-0 whitespace-nowrap">{segment}</span>
|
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
|
||||||
<ChevronRight className="size-3.5 shrink-0 text-slate-300 dark:text-slate-600" />
|
{segment}
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
<span className="truncate font-semibold text-slate-700 dark:text-slate-200">
|
<span
|
||||||
|
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
|
||||||
|
title={current}
|
||||||
|
>
|
||||||
{current}
|
{current}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ function ToolbarButton({
|
|||||||
onClick,
|
onClick,
|
||||||
disabled,
|
disabled,
|
||||||
danger,
|
danger,
|
||||||
|
emphasized,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
|
emphasized?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -21,9 +23,11 @@ function ToolbarButton({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 text-[13px] text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800",
|
"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 &&
|
danger &&
|
||||||
"border-red-200 bg-red-50 text-red-600 hover:bg-red-100 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-400 dark:hover:bg-red-500/20",
|
"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}
|
{icon}
|
||||||
@@ -32,7 +36,7 @@ function ToolbarButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Document toolbar: view badge + char count + updated time on the left, actions on the right. */
|
/** Compact inline toolbar: subtle meta (status / chars / updated) + actions. */
|
||||||
export function DocumentToolbar({
|
export function DocumentToolbar({
|
||||||
charCount,
|
charCount,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
@@ -59,17 +63,21 @@ export function DocumentToolbar({
|
|||||||
onArchive: () => void;
|
onArchive: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-12 shrink-0 flex-wrap items-center gap-2 border-b border-slate-50 bg-white px-5 py-1.5 sm:px-8 dark:border-slate-800 dark:bg-slate-900">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
<span className="inline-flex h-6 items-center rounded-md bg-red-50 px-2.5 text-xs font-bold text-red-600 dark:bg-red-500/10 dark:text-red-400">
|
{editing ? (
|
||||||
{editing ? "MARKDOWN EDIT" : "MARKDOWN VIEW"}
|
<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>
|
</span>
|
||||||
<span className="inline-flex h-6 items-center rounded-md bg-slate-100 px-2 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
|
|
||||||
{statusLabel}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-slate-400">{charCount.toLocaleString()} 字符</span>
|
|
||||||
<span className="text-xs text-slate-400">更新时间:{updatedAt}</span>
|
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-1.5">
|
<div className="ml-1 flex items-center gap-1">
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<>
|
<>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
@@ -82,6 +90,7 @@ export function DocumentToolbar({
|
|||||||
label={saving ? "保存中..." : "保存"}
|
label={saving ? "保存中..." : "保存"}
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
emphasized
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Loader2, Search, SlidersHorizontal } from "lucide-react";
|
import {
|
||||||
|
Loader2,
|
||||||
|
PanelLeftClose,
|
||||||
|
PanelLeftOpen,
|
||||||
|
Search,
|
||||||
|
SlidersHorizontal,
|
||||||
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -30,16 +36,44 @@ interface KnowledgeSidebarProps {
|
|||||||
onExportNote: (note: KnowledgeNote) => void;
|
onExportNote: (note: KnowledgeNote) => void;
|
||||||
total: number;
|
total: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
/** Collapsed to a narrow rail (desktop only; the mobile drawer always expands). */
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Left column: search + advanced filters + directory tree + footer stats. */
|
/** Left column: search + filters + directory tree, collapsible to a narrow rail. */
|
||||||
export function KnowledgeSidebar(props: KnowledgeSidebarProps) {
|
export function KnowledgeSidebar(props: KnowledgeSidebarProps) {
|
||||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
const activeFilters =
|
const activeFilters =
|
||||||
(props.sourceType !== "all" ? 1 : 0) + (props.status !== "all" ? 1 : 0);
|
(props.sourceType !== "all" ? 1 : 0) + (props.status !== "all" ? 1 : 0);
|
||||||
const searching = props.keyword.trim().length > 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 (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -47,42 +81,53 @@ export function KnowledgeSidebar(props: KnowledgeSidebarProps) {
|
|||||||
props.className,
|
props.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="border-b border-slate-50 p-4 dark:border-slate-800">
|
<div className="border-b border-slate-100 p-2.5 dark:border-slate-800">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
|
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
value={props.keyword}
|
value={props.keyword}
|
||||||
onChange={(e) => props.onKeywordChange(e.target.value)}
|
onChange={(e) => props.onKeywordChange(e.target.value)}
|
||||||
placeholder="快速查找内容或标题..."
|
placeholder="搜索标题或内容..."
|
||||||
className="h-9 w-full rounded-[10px] border border-slate-200 bg-slate-50 pl-9 pr-3 text-sm text-slate-700 outline-none transition-colors placeholder:text-slate-400 focus:border-red-600 focus:bg-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:bg-slate-900"
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFiltersOpen((v) => !v)}
|
onClick={() => setFiltersOpen((v) => !v)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex size-9 shrink-0 items-center justify-center rounded-[10px] border transition-colors",
|
"relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg transition-colors",
|
||||||
filtersOpen || activeFilters > 0
|
filtersOpen || activeFilters > 0
|
||||||
? "border-red-200 bg-red-50 text-red-600 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-400"
|
? "bg-red-50 text-red-500 dark:bg-red-500/10 dark:text-red-400"
|
||||||
: "border-slate-200 bg-white text-slate-500 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800",
|
: "text-slate-400 hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300",
|
||||||
)}
|
)}
|
||||||
aria-label="高级筛选"
|
aria-label="高级筛选"
|
||||||
title="高级筛选"
|
title="高级筛选"
|
||||||
>
|
>
|
||||||
<SlidersHorizontal className="size-4" />
|
<SlidersHorizontal className="size-4" />
|
||||||
{activeFilters > 0 ? (
|
{activeFilters > 0 ? (
|
||||||
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white">
|
<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}
|
{activeFilters}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{filtersOpen ? (
|
{filtersOpen ? (
|
||||||
<div className="mt-3 flex flex-col gap-2">
|
<div className="mt-2 grid grid-cols-2 gap-1.5">
|
||||||
<Select value={props.sourceType} onValueChange={props.onSourceTypeChange}>
|
<Select value={props.sourceType} onValueChange={props.onSourceTypeChange}>
|
||||||
<SelectTrigger className="h-9 w-full">
|
<SelectTrigger className="h-8 w-full text-[13px]">
|
||||||
<SelectValue placeholder="来源" />
|
<SelectValue placeholder="来源" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -95,7 +140,7 @@ export function KnowledgeSidebar(props: KnowledgeSidebarProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={props.status} onValueChange={props.onStatusChange}>
|
<Select value={props.status} onValueChange={props.onStatusChange}>
|
||||||
<SelectTrigger className="h-9 w-full">
|
<SelectTrigger className="h-8 w-full text-[13px]">
|
||||||
<SelectValue placeholder="状态" />
|
<SelectValue placeholder="状态" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -109,9 +154,9 @@ export function KnowledgeSidebar(props: KnowledgeSidebarProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-2.5 py-3">
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
|
||||||
<div className="mx-2 my-2 text-xs font-bold uppercase tracking-wide text-slate-400 dark:text-slate-500">
|
<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>
|
</div>
|
||||||
{props.isLoading ? (
|
{props.isLoading ? (
|
||||||
<div className="flex items-center gap-2 p-4 text-sm text-slate-400">
|
<div className="flex items-center gap-2 p-4 text-sm text-slate-400">
|
||||||
@@ -136,8 +181,8 @@ export function KnowledgeSidebar(props: KnowledgeSidebarProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-[42px] shrink-0 items-center justify-center border-t border-slate-50 text-xs text-slate-400 dark:border-slate-800 dark:text-slate-500">
|
<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} 篇文档
|
共 {props.total} 篇文档
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ interface KnowledgeTreeProps {
|
|||||||
|
|
||||||
function TreeRow(props: KnowledgeTreeProps & { node: TreeNode; depth: number }) {
|
function TreeRow(props: KnowledgeTreeProps & { node: TreeNode; depth: number }) {
|
||||||
const { node, depth, selectedId, expandedIds, forceExpand } = props;
|
const { node, depth, selectedId, expandedIds, forceExpand } = props;
|
||||||
const indent = { paddingLeft: `${8 + depth * 14}px` };
|
const indent = { paddingLeft: `${6 + depth * 10}px` };
|
||||||
|
|
||||||
if (node.type === "folder") {
|
if (node.type === "folder") {
|
||||||
const open = forceExpand || expandedIds.has(node.id);
|
const open = forceExpand || expandedIds.has(node.id);
|
||||||
@@ -38,7 +38,8 @@ function TreeRow(props: KnowledgeTreeProps & { node: TreeNode; depth: number })
|
|||||||
if (e.key === "Enter" || e.key === " ") props.onToggleFolder(node.id);
|
if (e.key === "Enter" || e.key === " ") props.onToggleFolder(node.id);
|
||||||
}}
|
}}
|
||||||
style={indent}
|
style={indent}
|
||||||
className="group flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-lg pr-2 text-sm font-semibold text-slate-600 hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-800"
|
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
|
<ChevronRight
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -47,12 +48,12 @@ function TreeRow(props: KnowledgeTreeProps & { node: TreeNode; depth: number })
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{open ? (
|
{open ? (
|
||||||
<FolderOpen className="size-4 shrink-0 text-amber-500" />
|
<FolderOpen className="size-3.5 shrink-0 text-slate-400 dark:text-slate-500" />
|
||||||
) : (
|
) : (
|
||||||
<Folder className="size-4 shrink-0 text-amber-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="flex-1 truncate">{node.name}</span>
|
||||||
<span className="text-xs font-normal text-slate-300 opacity-0 group-hover:opacity-100 dark:text-slate-600">
|
<span className="text-[11px] font-normal text-slate-300 opacity-0 group-hover:opacity-100 dark:text-slate-600">
|
||||||
{node.children?.length ?? 0}
|
{node.children?.length ?? 0}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,16 +79,16 @@ function TreeRow(props: KnowledgeTreeProps & { node: TreeNode; depth: number })
|
|||||||
}}
|
}}
|
||||||
style={indent}
|
style={indent}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-lg border-l-[3px] border-transparent pr-1 text-sm",
|
"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
|
active
|
||||||
? "border-red-600 bg-red-50 font-semibold text-red-600 dark:bg-red-500/10 dark:text-red-400"
|
? "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",
|
: "text-slate-500 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/70",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FileText
|
<FileText
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-4 shrink-0",
|
"size-3.5 shrink-0",
|
||||||
active ? "text-red-500" : "text-slate-400 dark:text-slate-500",
|
active ? "text-red-500 dark:text-red-400" : "text-slate-400 dark:text-slate-500",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="flex-1 truncate" title={node.name}>
|
<span className="flex-1 truncate" title={node.name}>
|
||||||
@@ -99,10 +100,10 @@ function TreeRow(props: KnowledgeTreeProps & { node: TreeNode; depth: number })
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="inline-flex size-6 shrink-0 items-center justify-center rounded text-slate-400 opacity-0 hover:bg-slate-200/60 group-hover:opacity-100 data-[state=open]:opacity-100 dark:hover:bg-slate-700"
|
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="更多操作"
|
aria-label="更多操作"
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" onClick={(e) => e.stopPropagation()}>
|
<DropdownMenuContent align="start" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { ListTree } from "lucide-react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
import type { TocItem } from "./wikiTypes";
|
|
||||||
|
|
||||||
const LEVEL_PADDING: Record<number, string> = {
|
|
||||||
1: "pl-0 font-bold",
|
|
||||||
2: "pl-3",
|
|
||||||
3: "pl-6",
|
|
||||||
4: "pl-9",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Right column: outline of the current document with scroll-spy highlighting. */
|
|
||||||
export function TocPanel({
|
|
||||||
items,
|
|
||||||
activeId,
|
|
||||||
onItemClick,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
items: TocItem[];
|
|
||||||
activeId: string | null;
|
|
||||||
onItemClick: (id: string) => void;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<aside
|
|
||||||
className={cn(
|
|
||||||
"min-h-0 overflow-y-auto border-l border-slate-100 bg-white px-5 py-6 dark:border-slate-800 dark:bg-slate-900",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="mb-5 flex items-center gap-2 text-[13px] font-bold text-slate-500 dark:text-slate-400">
|
|
||||||
<ListTree className="size-4" />
|
|
||||||
本文目录大纲
|
|
||||||
</div>
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<div className="text-[13px] leading-7 text-slate-400">
|
|
||||||
暂无大纲。
|
|
||||||
<br />
|
|
||||||
选择文档后,这里会展示从 Markdown 标题解析出的文章结构。
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<nav className="flex flex-col">
|
|
||||||
{items.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onItemClick(item.id)}
|
|
||||||
title={item.text}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer truncate border-l-2 border-transparent py-[5px] pr-1 text-left text-[13px] leading-relaxed text-slate-500 transition-colors hover:text-red-600 dark:text-slate-400 dark:hover:text-red-400",
|
|
||||||
LEVEL_PADDING[item.level] ?? "pl-9",
|
|
||||||
activeId === item.id &&
|
|
||||||
"border-red-600 font-semibold text-red-600 dark:text-red-400",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.text}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ExternalLink, Sparkles } from "lucide-react";
|
import { ExternalLink, Sparkles } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -22,14 +22,17 @@ import type {
|
|||||||
|
|
||||||
import { Breadcrumb } from "./Breadcrumb";
|
import { Breadcrumb } from "./Breadcrumb";
|
||||||
import { DocumentToolbar } from "./DocumentToolbar";
|
import { DocumentToolbar } from "./DocumentToolbar";
|
||||||
|
import { FloatingToc } from "./FloatingToc";
|
||||||
import { MarkdownViewer } from "./MarkdownViewer";
|
import { MarkdownViewer } from "./MarkdownViewer";
|
||||||
import type { TocItem } from "./wikiTypes";
|
import { RelatedDocs } from "./RelatedDocs";
|
||||||
import {
|
import {
|
||||||
SOURCE_TYPE_LABELS,
|
SOURCE_TYPE_LABELS,
|
||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
downloadMarkdown,
|
downloadMarkdown,
|
||||||
|
extractToc,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
noteBreadcrumbSegments,
|
noteBreadcrumbSegments,
|
||||||
|
splitRelatedSection,
|
||||||
} from "./wikiUtils";
|
} from "./wikiUtils";
|
||||||
|
|
||||||
const ENTITY_TYPE_META: Record<string, { label: string; className: string }> = {
|
const ENTITY_TYPE_META: Record<string, { label: string; className: string }> = {
|
||||||
@@ -67,19 +70,15 @@ function MetaTag({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Middle column: breadcrumb + toolbar + markdown reading area (or inline editor). */
|
/** Middle column: compact header + markdown reading area (or inline editor). */
|
||||||
export function WikiContent({
|
export function WikiContent({
|
||||||
note,
|
note,
|
||||||
toc,
|
|
||||||
wikiLinks,
|
wikiLinks,
|
||||||
onWikiLinkClick,
|
onWikiLinkClick,
|
||||||
onActiveHeadingChange,
|
|
||||||
}: {
|
}: {
|
||||||
note: KnowledgeNote;
|
note: KnowledgeNote;
|
||||||
toc: TocItem[];
|
|
||||||
wikiLinks?: WikiLinkMap;
|
wikiLinks?: WikiLinkMap;
|
||||||
onWikiLinkClick: (link: WikiLinkTarget) => void;
|
onWikiLinkClick: (link: WikiLinkTarget) => void;
|
||||||
onActiveHeadingChange: (id: string | null) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { update, remove } = useKnowledgeActions();
|
const { update, remove } = useKnowledgeActions();
|
||||||
@@ -91,6 +90,20 @@ export function WikiContent({
|
|||||||
const [draftTags, setDraftTags] = useState(note.tags.join(", "));
|
const [draftTags, setDraftTags] = useState(note.tags.join(", "));
|
||||||
const [draftContent, setDraftContent] = useState(note.content_md);
|
const [draftContent, setDraftContent] = useState(note.content_md);
|
||||||
const [draftStatus, setDraftStatus] = useState<string>(note.status);
|
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(() => {
|
useEffect(() => {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
@@ -99,7 +112,7 @@ export function WikiContent({
|
|||||||
setDraftContent(note.content_md);
|
setDraftContent(note.content_md);
|
||||||
setDraftStatus(note.status);
|
setDraftStatus(note.status);
|
||||||
scrollRef.current?.scrollTo({ top: 0 });
|
scrollRef.current?.scrollTo({ top: 0 });
|
||||||
onActiveHeadingChange(toc[0]?.id ?? null);
|
setActiveHeadingId(null);
|
||||||
// Re-sync drafts whenever a different note (or fresh server copy) arrives.
|
// Re-sync drafts whenever a different note (or fresh server copy) arrives.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [note.id, note.updated_at]);
|
}, [note.id, note.updated_at]);
|
||||||
@@ -124,10 +137,17 @@ export function WikiContent({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onActiveHeadingChange(current);
|
setActiveHeadingId(current);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTocClick = (id: string) => {
|
||||||
|
setActiveHeadingId(id);
|
||||||
|
document
|
||||||
|
.getElementById(id)
|
||||||
|
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
await update.mutateAsync({
|
await update.mutateAsync({
|
||||||
@@ -161,173 +181,196 @@ export function WikiContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 min-w-0 flex-col bg-white dark:bg-slate-900">
|
<div className="flex min-h-0 min-w-0 flex-col bg-white dark:bg-slate-900">
|
||||||
<Breadcrumb segments={noteBreadcrumbSegments(note)} current={note.title} />
|
<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">
|
||||||
<DocumentToolbar
|
<Breadcrumb
|
||||||
charCount={note.content_md.length}
|
segments={noteBreadcrumbSegments(note)}
|
||||||
updatedAt={formatDateTime(note.updated_at)}
|
current={note.title}
|
||||||
statusLabel={STATUS_LABELS[note.status] ?? note.status}
|
className="min-w-0 flex-1"
|
||||||
editing={editing}
|
/>
|
||||||
saving={update.isPending}
|
<DocumentToolbar
|
||||||
archiving={remove.isPending}
|
charCount={note.content_md.length}
|
||||||
onEdit={() => setEditing(true)}
|
updatedAt={formatDateTime(note.updated_at)}
|
||||||
onCancelEdit={() => setEditing(false)}
|
statusLabel={STATUS_LABELS[note.status] ?? note.status}
|
||||||
onSave={() => void handleSave()}
|
editing={editing}
|
||||||
onExport={() => downloadMarkdown(note)}
|
saving={update.isPending}
|
||||||
onArchive={() => void handleArchive()}
|
archiving={remove.isPending}
|
||||||
/>
|
onEdit={() => setEditing(true)}
|
||||||
|
onCancelEdit={() => setEditing(false)}
|
||||||
|
onSave={() => void handleSave()}
|
||||||
|
onExport={() => downloadMarkdown(note)}
|
||||||
|
onArchive={() => void handleArchive()}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div
|
<div className="relative min-h-0 flex-1">
|
||||||
ref={scrollRef}
|
{!editing ? (
|
||||||
onScroll={editing ? undefined : handleScroll}
|
<FloatingToc
|
||||||
className="min-h-0 flex-1 overflow-y-auto"
|
items={outlineItems}
|
||||||
>
|
activeId={activeHeadingId}
|
||||||
<div className="mx-auto w-full max-w-[880px] px-5 pb-32 pt-10 sm:px-10 lg:px-14">
|
onItemClick={handleTocClick}
|
||||||
{editing ? (
|
className="absolute right-4 top-3 z-10 sm:right-6"
|
||||||
<div className="flex flex-col gap-3">
|
/>
|
||||||
<Input
|
) : null}
|
||||||
value={draftTitle}
|
|
||||||
onChange={(e) => setDraftTitle(e.target.value)}
|
<div
|
||||||
placeholder="标题"
|
ref={scrollRef}
|
||||||
/>
|
onScroll={editing ? undefined : handleScroll}
|
||||||
<div className="flex items-center gap-2">
|
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
|
<Input
|
||||||
value={draftTags}
|
value={draftTitle}
|
||||||
onChange={(e) => setDraftTags(e.target.value)}
|
onChange={(e) => setDraftTitle(e.target.value)}
|
||||||
placeholder="标签,逗号分隔"
|
placeholder="标题"
|
||||||
/>
|
/>
|
||||||
<Select value={draftStatus} onValueChange={setDraftStatus}>
|
<div className="flex items-center gap-2">
|
||||||
<SelectTrigger className="w-32">
|
<Input
|
||||||
<SelectValue />
|
value={draftTags}
|
||||||
</SelectTrigger>
|
onChange={(e) => setDraftTags(e.target.value)}
|
||||||
<SelectContent>
|
placeholder="标签,逗号分隔"
|
||||||
<SelectItem value="draft">草稿</SelectItem>
|
/>
|
||||||
<SelectItem value="approved">已批准</SelectItem>
|
<Select value={draftStatus} onValueChange={setDraftStatus}>
|
||||||
<SelectItem value="archived">已归档</SelectItem>
|
<SelectTrigger className="w-32">
|
||||||
</SelectContent>
|
<SelectValue />
|
||||||
</Select>
|
</SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
<Textarea
|
<SelectItem value="draft">草稿</SelectItem>
|
||||||
rows={24}
|
<SelectItem value="approved">已批准</SelectItem>
|
||||||
value={draftContent}
|
<SelectItem value="archived">已归档</SelectItem>
|
||||||
onChange={(e) => setDraftContent(e.target.value)}
|
</SelectContent>
|
||||||
className="font-mono text-sm"
|
</Select>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<h1 className="mb-3 text-[32px] font-extrabold leading-[1.35] text-slate-900 dark:text-slate-100">
|
|
||||||
{note.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{note.summary ? (
|
|
||||||
<div className="my-4 rounded-lg border-l-4 border-slate-200 bg-slate-50 px-4 py-3.5 text-sm leading-7 text-slate-600 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300">
|
|
||||||
{note.summary}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<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>
|
||||||
|
|
||||||
<div className="mb-8 mt-4 flex flex-wrap items-center gap-2">
|
{note.summary ? (
|
||||||
<MetaTag>
|
<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">
|
||||||
来源:{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
|
{note.summary}
|
||||||
</MetaTag>
|
</div>
|
||||||
<MetaTag>状态:{STATUS_LABELS[note.status] ?? note.status}</MetaTag>
|
|
||||||
{note.created_at ? (
|
|
||||||
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
|
|
||||||
) : null}
|
) : null}
|
||||||
{note.tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="inline-flex h-6 items-center rounded-md bg-red-50 px-2 text-xs font-medium text-red-600 dark:bg-red-500/10 dark:text-red-400"
|
|
||||||
>
|
|
||||||
#{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{note.entities && note.entities.length > 0 ? (
|
<div className="mb-7 mt-3 flex flex-wrap items-center gap-1.5">
|
||||||
<div className="mb-8 flex flex-wrap items-center gap-1.5">
|
<MetaTag>
|
||||||
<span className="mr-0.5 inline-flex items-center gap-1 text-xs text-slate-400">
|
来源:{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
|
||||||
<Sparkles className="size-3" /> 实体
|
</MetaTag>
|
||||||
</span>
|
<MetaTag>状态:{STATUS_LABELS[note.status] ?? note.status}</MetaTag>
|
||||||
{note.entities.map((ent) => {
|
{note.created_at ? (
|
||||||
const meta = entityMeta(ent.entity_type);
|
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
|
||||||
return (
|
) : null}
|
||||||
<span
|
{note.tags.map((tag) => (
|
||||||
key={`${ent.name}-${ent.entity_type ?? ""}`}
|
<span
|
||||||
title={meta.label}
|
key={tag}
|
||||||
className={cn(
|
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"
|
||||||
"inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium",
|
>
|
||||||
meta.className,
|
#{tag}
|
||||||
)}
|
</span>
|
||||||
>
|
))}
|
||||||
<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>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<MarkdownViewer
|
{note.entities && note.entities.length > 0 ? (
|
||||||
content={note.content_md}
|
<div className="mb-7 flex flex-wrap items-center gap-1.5">
|
||||||
toc={toc}
|
<span className="mr-0.5 inline-flex items-center gap-1 text-xs text-slate-400">
|
||||||
wikiLinks={wikiLinks}
|
<Sparkles className="size-3" /> 实体
|
||||||
onWikiLinkClick={onWikiLinkClick}
|
</span>
|
||||||
/>
|
{note.entities.map((ent) => {
|
||||||
|
const meta = entityMeta(ent.entity_type);
|
||||||
{note.sources && note.sources.length > 0 ? (
|
return (
|
||||||
<div className="mt-10 border-t border-slate-100 pt-5 dark:border-slate-800">
|
<span
|
||||||
<h2 className="mb-3 text-sm font-bold text-slate-700 dark:text-slate-300">
|
key={`${ent.name}-${ent.entity_type ?? ""}`}
|
||||||
来源
|
title={meta.label}
|
||||||
</h2>
|
className={cn(
|
||||||
<ul className="flex flex-col gap-2">
|
"inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium",
|
||||||
{note.sources.map((src) => (
|
meta.className,
|
||||||
<li key={src.id} className="text-sm">
|
)}
|
||||||
{src.url ? (
|
>
|
||||||
<a
|
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-70" />
|
||||||
href={src.url}
|
<span>{ent.name}</span>
|
||||||
target="_blank"
|
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
|
||||||
rel="noreferrer"
|
{meta.label}
|
||||||
className="inline-flex items-center gap-1 text-blue-600 hover:underline dark:text-blue-400"
|
|
||||||
>
|
|
||||||
{src.title || src.url}
|
|
||||||
<ExternalLink className="size-3" />
|
|
||||||
</a>
|
|
||||||
) : src.thread_id ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(`/page/workspace/chats/${src.thread_id}`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{src.title || `对话 ${src.thread_id.slice(0, 8)}`}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="text-slate-600 dark:text-slate-300">
|
|
||||||
{src.title || src.tool_name || "来源"}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
</span>
|
||||||
{src.snippet ? (
|
);
|
||||||
<p className="mt-0.5 line-clamp-2 text-xs text-slate-400">
|
})}
|
||||||
{src.snippet}
|
</div>
|
||||||
</p>
|
) : null}
|
||||||
) : null}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-10 border-t border-slate-100 pt-3 text-xs text-slate-400 dark:border-slate-800">
|
<MarkdownViewer
|
||||||
创建人 {actorLabel(note.created_by_name, note.created_by)} · 更新人{" "}
|
content={body}
|
||||||
{actorLabel(note.updated_by_name, note.updated_by)} · 更新于{" "}
|
toc={toc}
|
||||||
{formatDateTime(note.updated_at)}
|
wikiLinks={wikiLinks}
|
||||||
</div>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,68 +2,83 @@
|
|||||||
inside .wiki-markdown to get a document-site reading experience. */
|
inside .wiki-markdown to get a document-site reading experience. */
|
||||||
|
|
||||||
.wiki-markdown h1 {
|
.wiki-markdown h1 {
|
||||||
font-size: 32px;
|
font-size: 26px;
|
||||||
line-height: 1.35;
|
line-height: 1.4;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
margin: 0 0 28px;
|
margin: 0 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-markdown h2 {
|
.wiki-markdown h2 {
|
||||||
font-size: 22px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin: 42px 0 16px;
|
margin: 36px 0 14px;
|
||||||
padding-left: 12px;
|
padding-bottom: 8px;
|
||||||
border-left: 4px solid #dc2626;
|
border-bottom: 1px solid #eef1f5;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-markdown h3 {
|
.wiki-markdown h3 {
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
margin: 28px 0 12px;
|
margin: 26px 0 10px;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-markdown h4 {
|
.wiki-markdown h4 {
|
||||||
font-size: 16px;
|
font-size: 15.5px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
margin: 24px 0 10px;
|
margin: 22px 0 8px;
|
||||||
color: #334155;
|
color: #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-markdown p {
|
.wiki-markdown p {
|
||||||
font-size: 15.5px;
|
font-size: 15.5px;
|
||||||
line-height: 1.9;
|
line-height: 1.75;
|
||||||
color: #334155;
|
color: #334155;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-markdown ul,
|
.wiki-markdown ul,
|
||||||
.wiki-markdown ol {
|
.wiki-markdown ol {
|
||||||
padding-left: 24px;
|
padding-left: 22px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-markdown li {
|
.wiki-markdown li {
|
||||||
margin: 8px 0;
|
margin: 5px 0;
|
||||||
line-height: 1.8;
|
line-height: 1.75;
|
||||||
color: #334155;
|
color: #334155;
|
||||||
font-size: 15.5px;
|
font-size: 15.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wiki-markdown li > ul,
|
||||||
|
.wiki-markdown li > ol {
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.wiki-markdown strong {
|
.wiki-markdown strong {
|
||||||
color: #111827;
|
color: #111827;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiki-markdown a {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiki-markdown a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-markdown blockquote {
|
.wiki-markdown blockquote {
|
||||||
border-left: 4px solid #e5e7eb;
|
border-left: 3px solid #e2e8f0;
|
||||||
background: #f9fafb;
|
background: #f8fafc;
|
||||||
padding: 12px 18px;
|
padding: 10px 16px;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
margin: 18px 0;
|
margin: 16px 0;
|
||||||
border-radius: 0 8px 8px 0;
|
border-radius: 0 8px 8px 0;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
@@ -75,33 +90,33 @@
|
|||||||
.wiki-markdown table {
|
.wiki-markdown table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin: 24px 0;
|
margin: 20px 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-markdown th,
|
.wiki-markdown th,
|
||||||
.wiki-markdown td {
|
.wiki-markdown td {
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e8ecf1;
|
||||||
padding: 10px 12px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-markdown th {
|
.wiki-markdown th {
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: #334155;
|
color: #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-markdown hr {
|
.wiki-markdown hr {
|
||||||
margin: 28px 0;
|
margin: 28px 0;
|
||||||
border-color: #edf0f5;
|
border-color: #eef1f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Anchor target offset so headings are not hidden behind the sticky chrome. */
|
/* Anchor target offset so headings clear the floating "目录" pill. */
|
||||||
.wiki-markdown h1,
|
.wiki-markdown h1,
|
||||||
.wiki-markdown h2,
|
.wiki-markdown h2,
|
||||||
.wiki-markdown h3,
|
.wiki-markdown h3,
|
||||||
.wiki-markdown h4 {
|
.wiki-markdown h4 {
|
||||||
scroll-margin-top: 16px;
|
scroll-margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark theme adjustments (app toggles the .dark class on a root element). */
|
/* Dark theme adjustments (app toggles the .dark class on a root element). */
|
||||||
@@ -110,6 +125,10 @@
|
|||||||
color: #f1f5f9;
|
color: #f1f5f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .wiki-markdown h2 {
|
||||||
|
border-bottom-color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
.dark .wiki-markdown h3,
|
.dark .wiki-markdown h3,
|
||||||
.dark .wiki-markdown h4 {
|
.dark .wiki-markdown h4 {
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
@@ -124,6 +143,10 @@
|
|||||||
color: #f8fafc;
|
color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .wiki-markdown a {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
.dark .wiki-markdown blockquote {
|
.dark .wiki-markdown blockquote {
|
||||||
background: #1e293b;
|
background: #1e293b;
|
||||||
border-left-color: #475569;
|
border-left-color: #475569;
|
||||||
|
|||||||
@@ -58,6 +58,67 @@ export function extractToc(markdown: string): TocItem[] {
|
|||||||
return items;
|
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. */
|
/** Folder path segments for a note: vault_path directories, else its source-type group. */
|
||||||
export function noteFolderSegments(note: KnowledgeNote): string[] {
|
export function noteFolderSegments(note: KnowledgeNote): string[] {
|
||||||
const rawPath = note.vault_path?.replace(/\\/g, "/").replace(/^\/+/, "");
|
const rawPath = note.vault_path?.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
BotIcon,
|
|
||||||
InfoIcon,
|
|
||||||
MessageCircleQuestionIcon,
|
|
||||||
PanelRightCloseIcon,
|
|
||||||
PanelRightOpenIcon,
|
|
||||||
SparklesIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
|
||||||
import { Tooltip } from "@/components/workspace/tooltip";
|
|
||||||
import { type Agent, normalizeAgentQuestions } from "@/core/agents";
|
|
||||||
import { useSkills } from "@/core/skills/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 sidebar. The sidebar is a profile card, not a document view —
|
|
||||||
* so we strip markdown syntax (headings like `###`, emphasis `**`/`*`, inline
|
|
||||||
* code, list bullets, blockquotes) rather than rendering it, while keeping the
|
|
||||||
* paragraph/line structure intact.
|
|
||||||
*/
|
|
||||||
function cleanProfileText(raw: string): string {
|
|
||||||
return raw
|
|
||||||
.split("\n")
|
|
||||||
.map((line) =>
|
|
||||||
line
|
|
||||||
// Drop heading markers (`#`, `##`, `### …`) — the main "###不要出现" fix.
|
|
||||||
.replace(/^\s{0,3}#{1,6}\s*/, "")
|
|
||||||
// Blockquote markers.
|
|
||||||
.replace(/^\s{0,3}>\s?/, "")
|
|
||||||
// List bullets `- ` / `* ` / `+ ` → a bullet glyph.
|
|
||||||
.replace(/^(\s*)[-*+]\s+/, "$1• ")
|
|
||||||
// Bold / italic / inline-code markers.
|
|
||||||
.replace(/(\*\*|__)(.*?)\1/g, "$2")
|
|
||||||
.replace(/(\*|_)(.*?)\1/g, "$2")
|
|
||||||
.replace(/`([^`]+)`/g, "$1"),
|
|
||||||
)
|
|
||||||
.join("\n")
|
|
||||||
// Collapse 3+ blank lines into a single blank line.
|
|
||||||
.replace(/\n{3,}/g, "\n\n")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collapsible right-hand "智能体简介" panel on the agent chat page. It is a
|
|
||||||
* read-only window onto the agent's profile — avatar, name, slogan/description,
|
|
||||||
* the admin-authored SOUL.md as a long-form blurb, the skill whitelist, and the
|
|
||||||
* admin-configured common questions surfaced as quick-ask entries. Clicking a
|
|
||||||
* question sends it as a message.
|
|
||||||
*
|
|
||||||
* Editing the common questions lives in the "编辑智能体" dialog (agent-card), NOT
|
|
||||||
* here — this panel never offers an edit affordance or an admin hint.
|
|
||||||
*/
|
|
||||||
export function AgentInfoSidebar({
|
|
||||||
agent,
|
|
||||||
agentName,
|
|
||||||
onAskQuestion,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
agent: Agent | null | undefined;
|
|
||||||
agentName: string;
|
|
||||||
onAskQuestion: (text: string) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
|
||||||
// Expanded by default: the profile card is meant to be the agent's primary
|
|
||||||
// "expert info" panel, not a hidden drawer. Users can still collapse it on
|
|
||||||
// narrow screens via the header toggle.
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
|
||||||
const { skills } = useSkills();
|
|
||||||
|
|
||||||
const displayName = desensitize(agent?.name ?? agentName);
|
|
||||||
const description = agent?.description
|
|
||||||
? desensitize(agent.description)
|
|
||||||
: undefined;
|
|
||||||
const soul = agent?.soul?.trim()
|
|
||||||
? cleanProfileText(desensitize(agent.soul))
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Map a stored skill name to its Chinese display label (falls back to name).
|
|
||||||
const skillLabel = useMemo(() => {
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
for (const skill of skills) {
|
|
||||||
map.set(skill.name, skill.name_zh?.trim() ? skill.name_zh : skill.name);
|
|
||||||
}
|
|
||||||
return (name: string) => map.get(name) ?? name;
|
|
||||||
}, [skills]);
|
|
||||||
|
|
||||||
const questions = useMemo(
|
|
||||||
() => normalizeAgentQuestions(agent?.common_questions),
|
|
||||||
[agent?.common_questions],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (collapsed) {
|
|
||||||
return (
|
|
||||||
<div className="flex shrink-0 flex-col items-center border-l pt-3">
|
|
||||||
<Tooltip content="智能体简介">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={() => setCollapsed(false)}
|
|
||||||
aria-label="展开智能体简介"
|
|
||||||
>
|
|
||||||
<PanelRightOpenIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className="bg-card/40 hidden h-full w-[340px] shrink-0 flex-col border-l lg:flex">
|
|
||||||
{/* Header — same height as the chat header so the two columns line up. */}
|
|
||||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
|
||||||
<span className="text-sm font-semibold">智能体简介</span>
|
|
||||||
<Tooltip content="收起">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={() => setCollapsed(true)}
|
|
||||||
aria-label="收起智能体简介"
|
|
||||||
>
|
|
||||||
<PanelRightCloseIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
||||||
{/* Identity */}
|
|
||||||
<div className="flex flex-col items-center gap-2 px-6 pb-6 pt-7 text-center">
|
|
||||||
<div className="bg-primary/10 ring-primary/15 flex h-16 w-16 items-center justify-center rounded-2xl ring-1">
|
|
||||||
<BotIcon className="text-primary h-8 w-8" />
|
|
||||||
</div>
|
|
||||||
<div className="text-lg font-bold" title={displayName}>
|
|
||||||
{displayName}
|
|
||||||
</div>
|
|
||||||
{description && (
|
|
||||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description — the agent's long-form blurb, sourced from SOUL.md but
|
|
||||||
stripped of markdown syntax so it reads as plain profile copy. */}
|
|
||||||
{soul && (
|
|
||||||
<section className="space-y-3 border-t px-6 py-5">
|
|
||||||
<h4 className="text-foreground text-sm font-semibold">描述</h4>
|
|
||||||
<p className="text-muted-foreground text-sm leading-relaxed whitespace-pre-wrap">
|
|
||||||
{soul}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Skills */}
|
|
||||||
{agent?.skills && agent.skills.length > 0 && (
|
|
||||||
<section className="space-y-3 border-t px-6 py-5">
|
|
||||||
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
|
|
||||||
<SparklesIcon className="h-3.5 w-3.5" />
|
|
||||||
技能
|
|
||||||
</h4>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{agent.skills.map((name) => (
|
|
||||||
<button
|
|
||||||
key={name}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDetailSkillName(name)}
|
|
||||||
title="查看技能详情"
|
|
||||||
className="border-border bg-background hover:bg-accent inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors"
|
|
||||||
>
|
|
||||||
<span className="max-w-[180px] truncate">
|
|
||||||
{skillLabel(name)}
|
|
||||||
</span>
|
|
||||||
<InfoIcon className="text-muted-foreground h-3 w-3 shrink-0" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Common questions — read-only quick-ask entries. */}
|
|
||||||
{questions.length > 0 && (
|
|
||||||
<section className="space-y-3 border-t px-6 py-5">
|
|
||||||
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
|
|
||||||
<MessageCircleQuestionIcon className="h-3.5 w-3.5" />
|
|
||||||
推荐问题
|
|
||||||
</h4>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{questions.map((question, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
type="button"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => onAskQuestion(question.prompt)}
|
|
||||||
title={desensitize(question.prompt)}
|
|
||||||
className={cn(
|
|
||||||
"border-border/60 bg-background/60 hover:bg-accent hover:border-border w-full rounded-xl border px-3 py-2.5 text-left text-sm leading-relaxed transition-colors",
|
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{desensitize(question.title)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SkillDetailDialog
|
|
||||||
skillName={detailSkillName}
|
|
||||||
open={detailSkillName !== null}
|
|
||||||
onOpenChange={(o) => {
|
|
||||||
if (!o) setDetailSkillName(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,35 +2,31 @@
|
|||||||
|
|
||||||
import { BotIcon } from "lucide-react";
|
import { BotIcon } from "lucide-react";
|
||||||
|
|
||||||
import { type Agent, normalizeAgentQuestions } from "@/core/agents";
|
import { type Agent } from "@/core/agents";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Landing (home) hero shown above the input box before the first message of an
|
* Landing (home) hero shown above the input box before the first message of an
|
||||||
* agent conversation. Mirrors the qclaw landing screen: centered avatar, a
|
* agent conversation: centered avatar, a "Hi,我是{name}" greeting and the
|
||||||
* "Hi,我是{name}" greeting, the agent's one-line description as a slogan, and
|
* agent's description as a slogan. The description is clamped to two lines
|
||||||
* the agent's admin-configured common questions surfaced as clickable starter
|
* (full text on hover) so a long blurb can never push the input box below the
|
||||||
* pills. Clicking a pill sends it as the opening message via `onAskQuestion`.
|
* fold. The agent's 常用问题 are rendered by the page as a list beside the
|
||||||
|
* input box, not here.
|
||||||
*/
|
*/
|
||||||
export function AgentWelcome({
|
export function AgentWelcome({
|
||||||
className,
|
className,
|
||||||
agent,
|
agent,
|
||||||
agentName,
|
agentName,
|
||||||
onAskQuestion,
|
|
||||||
disabled,
|
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
agent: Agent | null | undefined;
|
agent: Agent | null | undefined;
|
||||||
agentName: string;
|
agentName: string;
|
||||||
onAskQuestion?: (text: string) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const displayName = desensitize(agent?.name ?? agentName);
|
const displayName = desensitize(agent?.name ?? agentName);
|
||||||
const description = agent?.description
|
const description = agent?.description
|
||||||
? desensitize(agent.description)
|
? desensitize(agent.description)
|
||||||
: undefined;
|
: undefined;
|
||||||
const questions = normalizeAgentQuestions(agent?.common_questions);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -46,29 +42,13 @@ export function AgentWelcome({
|
|||||||
Hi,我是{displayName}
|
Hi,我是{displayName}
|
||||||
</div>
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-muted-foreground max-w-xl text-base leading-relaxed">
|
<p
|
||||||
|
className="text-muted-foreground line-clamp-2 max-w-xl text-base leading-relaxed"
|
||||||
|
title={description}
|
||||||
|
>
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{onAskQuestion && questions.length > 0 && (
|
|
||||||
<div className="mt-1 flex flex-wrap items-center justify-center gap-2">
|
|
||||||
{questions.slice(0, 6).map((question, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
type="button"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => onAskQuestion(question.prompt)}
|
|
||||||
title={desensitize(question.prompt)}
|
|
||||||
className={cn(
|
|
||||||
"border-border/70 bg-card/60 hover:bg-accent hover:border-border max-w-[280px] truncate rounded-full border px-4 py-2 text-sm transition-colors",
|
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{desensitize(question.title)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
LockIcon,
|
LockIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
PinIcon,
|
PinIcon,
|
||||||
PlusIcon,
|
|
||||||
ShieldIcon,
|
ShieldIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
SquarePenIcon,
|
SquarePenIcon,
|
||||||
@@ -15,7 +14,6 @@ import {
|
|||||||
TagsIcon,
|
TagsIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
XIcon,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
@@ -59,6 +57,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { usePublishSquares } from "@/core/system-settings/hooks";
|
import { usePublishSquares } from "@/core/system-settings/hooks";
|
||||||
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
|
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
|
||||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-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 { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
|
||||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||||
@@ -218,9 +217,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
const [editName, setEditName] = useState(agent.name);
|
const [editName, setEditName] = useState(agent.name);
|
||||||
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
||||||
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
||||||
// Common questions (常用问题) are authored here in the edit dialog and read
|
// Common questions (常用问题) — primary editing now lives in the agent chat
|
||||||
// back read-only on the agent chat page. Each row has a short title (shown on
|
// page's right-hand AgentDetailPanel; this dialog stays as a backup entry.
|
||||||
// the chip) and the full prompt sent when clicked.
|
// Each row has a short title (shown on the chip) and the full prompt sent
|
||||||
|
// when clicked.
|
||||||
const [editCommonQuestions, setEditCommonQuestions] = useState<AgentQuestion[]>(
|
const [editCommonQuestions, setEditCommonQuestions] = useState<AgentQuestion[]>(
|
||||||
() => normalizeAgentQuestions(agent.common_questions),
|
() => normalizeAgentQuestions(agent.common_questions),
|
||||||
);
|
);
|
||||||
@@ -916,70 +916,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
这些问题会在该智能体的首页与简介栏作为推荐问题展示,点击即可一键提问。
|
这些问题会在该智能体的首页与简介栏作为推荐问题展示,点击即可一键提问。
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-3">
|
<AgentQuestionEditor
|
||||||
{editCommonQuestions.map((item, index) => (
|
value={editCommonQuestions}
|
||||||
<div
|
onChange={setEditCommonQuestions}
|
||||||
key={index}
|
/>
|
||||||
className="space-y-1.5 rounded-md border p-2.5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Input
|
|
||||||
value={item.title}
|
|
||||||
placeholder={`标题(如:能蒸馏哪些人)`}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...editCommonQuestions];
|
|
||||||
next[index] = {
|
|
||||||
...next[index]!,
|
|
||||||
title: e.currentTarget.value,
|
|
||||||
};
|
|
||||||
setEditCommonQuestions(next);
|
|
||||||
}}
|
|
||||||
className="h-8 text-sm font-medium"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={() =>
|
|
||||||
setEditCommonQuestions(
|
|
||||||
editCommonQuestions.filter((_, i) => i !== index),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
aria-label="删除问题"
|
|
||||||
>
|
|
||||||
<XIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
value={item.prompt}
|
|
||||||
placeholder="点击后实际发送的完整问题内容…"
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...editCommonQuestions];
|
|
||||||
next[index] = {
|
|
||||||
...next[index]!,
|
|
||||||
prompt: e.currentTarget.value,
|
|
||||||
};
|
|
||||||
setEditCommonQuestions(next);
|
|
||||||
}}
|
|
||||||
className="min-h-16 resize-none text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() =>
|
|
||||||
setEditCommonQuestions([
|
|
||||||
...editCommonQuestions,
|
|
||||||
{ title: "", prompt: "" },
|
|
||||||
])
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PlusIcon className="size-3.5" /> 添加问题
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between rounded-md border p-3">
|
<div className="flex items-center justify-between rounded-md border p-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,736 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BotIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
InfoIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
LockIcon,
|
||||||
|
MessageCircleQuestionIcon,
|
||||||
|
PanelRightCloseIcon,
|
||||||
|
PanelRightOpenIcon,
|
||||||
|
PlusIcon,
|
||||||
|
ShieldIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
SquarePenIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
UserRoundIcon,
|
||||||
|
} 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,
|
||||||
|
} 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 [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;
|
||||||
|
setDraft(draftFromAgent(agent));
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setEditing(false);
|
||||||
|
setDraft(null);
|
||||||
|
setPickerOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
try {
|
||||||
|
await updateAgent.mutateAsync({
|
||||||
|
id: agent.id,
|
||||||
|
request: {
|
||||||
|
name: trimmedName,
|
||||||
|
description: draft.description,
|
||||||
|
soul: draft.soul,
|
||||||
|
published: nextPublished,
|
||||||
|
skills: draft.skills,
|
||||||
|
common_questions: nextQuestions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// 首页推荐 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" onClick={beginEdit}>
|
||||||
|
<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="min-h-40 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground max-h-60 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) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onAskQuestion(question.prompt)}
|
||||||
|
title={desensitize(question.prompt)}
|
||||||
|
className={cn(
|
||||||
|
"border-border/60 bg-background/60 hover:bg-accent hover:border-border w-full rounded-xl border px-3 py-2.5 text-left text-sm leading-relaxed transition-colors",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{desensitize(question.title)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BotIcon, PlusSquare } from "lucide-react";
|
import { BotIcon, MessageCircleQuestionIcon, PlusSquare } from "lucide-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useRouteBase } from "@/core/route-base";
|
import { useRouteBase } from "@/core/route-base";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AgentInfoSidebar } from "@/components/workspace/agent-info-sidebar";
|
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
|
||||||
import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
||||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||||
@@ -19,7 +19,7 @@ import { ThreadTitle } from "@/components/workspace/thread-title";
|
|||||||
import { TodoList } from "@/components/workspace/todo-list";
|
import { TodoList } from "@/components/workspace/todo-list";
|
||||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||||
import { Tooltip } from "@/components/workspace/tooltip";
|
import { Tooltip } from "@/components/workspace/tooltip";
|
||||||
import { useAgent } from "@/core/agents";
|
import { normalizeAgentQuestions, useAgent } from "@/core/agents";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
import { useNotification } from "@/core/notification/hooks";
|
import { useNotification } from "@/core/notification/hooks";
|
||||||
@@ -28,6 +28,7 @@ import { useThreadStream } from "@/core/threads/hooks";
|
|||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export default function AgentChatPage() {
|
export default function AgentChatPage() {
|
||||||
const routeBase = useRouteBase();
|
const routeBase = useRouteBase();
|
||||||
@@ -61,6 +62,11 @@ export default function AgentChatPage() {
|
|||||||
const agentDisplayName = desensitize(
|
const agentDisplayName = desensitize(
|
||||||
agent?.name ?? (isAgentLoading ? t.common.loading : ""),
|
agent?.name ?? (isAgentLoading ? t.common.loading : ""),
|
||||||
);
|
);
|
||||||
|
// 常用问题 shown as a list beside the input box on the landing screen.
|
||||||
|
const commonQuestions = useMemo(
|
||||||
|
() => normalizeAgentQuestions(agent?.common_questions),
|
||||||
|
[agent?.common_questions],
|
||||||
|
);
|
||||||
|
|
||||||
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
||||||
useThreadChat();
|
useThreadChat();
|
||||||
@@ -225,33 +231,70 @@ export default function AgentChatPage() {
|
|||||||
<AgentWelcome
|
<AgentWelcome
|
||||||
agent={agent}
|
agent={agent}
|
||||||
agentName={agentDisplayName}
|
agentName={agentDisplayName}
|
||||||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
|
||||||
disabled={isDemoMode}
|
|
||||||
className="mb-7"
|
className="mb-7"
|
||||||
/>
|
/>
|
||||||
<div className="w-full max-w-(--container-width-md)">
|
{/* 常用问题 sit as a list to the LEFT of the input box (not as
|
||||||
<InputBox
|
floating pills above it). On narrow screens the list stacks
|
||||||
className="w-full"
|
above the input instead of squeezing it. */}
|
||||||
isNewThread
|
<div
|
||||||
hideAdminRecommendedQuestions
|
className={cn(
|
||||||
threadId={threadId}
|
"flex w-full flex-col gap-3 sm:flex-row sm:items-end sm:gap-4",
|
||||||
assistantId={agent_id}
|
commonQuestions.length > 0
|
||||||
autoFocus
|
? "max-w-(--container-width-lg)"
|
||||||
status={status}
|
: "max-w-(--container-width-md)",
|
||||||
context={settings.context}
|
)}
|
||||||
disabled={isDemoMode}
|
>
|
||||||
onContextChange={(context) =>
|
{commonQuestions.length > 0 && (
|
||||||
setSettings("context", context)
|
<div className="border-border/60 bg-card/60 w-full shrink-0 rounded-xl border p-3 sm:w-64">
|
||||||
}
|
<div className="text-muted-foreground mb-2 flex items-center gap-1.5 text-xs font-medium">
|
||||||
onFollowupsVisibilityChange={setShowFollowups}
|
<MessageCircleQuestionIcon className="size-3.5" />
|
||||||
onSubmit={handleSubmit}
|
常用问题
|
||||||
onStop={handleStop}
|
</div>
|
||||||
/>
|
<div className="flex max-h-44 flex-col gap-1.5 overflow-y-auto">
|
||||||
{isDemoMode && (
|
{commonQuestions.map((question, index) => (
|
||||||
<div className="text-muted-foreground/67 mt-3 w-full text-center text-xs">
|
<button
|
||||||
{t.common.notAvailableInDemoMode}
|
key={index}
|
||||||
|
type="button"
|
||||||
|
disabled={isDemoMode}
|
||||||
|
onClick={() =>
|
||||||
|
handleSubmit({ text: question.prompt, files: [] })
|
||||||
|
}
|
||||||
|
title={desensitize(question.prompt)}
|
||||||
|
className={cn(
|
||||||
|
"border-border/60 bg-background/60 hover:bg-accent hover:border-border w-full truncate rounded-lg border px-3 py-2 text-left text-sm transition-colors",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{desensitize(question.title)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<InputBox
|
||||||
|
className="w-full"
|
||||||
|
isNewThread
|
||||||
|
hideAdminRecommendedQuestions
|
||||||
|
threadId={threadId}
|
||||||
|
assistantId={agent_id}
|
||||||
|
autoFocus
|
||||||
|
status={status}
|
||||||
|
context={settings.context}
|
||||||
|
disabled={isDemoMode}
|
||||||
|
onContextChange={(context) =>
|
||||||
|
setSettings("context", context)
|
||||||
|
}
|
||||||
|
onFollowupsVisibilityChange={setShowFollowups}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onStop={handleStop}
|
||||||
|
/>
|
||||||
|
{isDemoMode && (
|
||||||
|
<div className="text-muted-foreground/67 mt-3 w-full text-center text-xs">
|
||||||
|
{t.common.notAvailableInDemoMode}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -304,8 +347,9 @@ export default function AgentChatPage() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* ── Right: read-only agent profile sidebar ───────────────────── */}
|
{/* ── Right: agent detail panel (profile + inline editing for
|
||||||
<AgentInfoSidebar
|
admins / owners; read-only for everyone else) ───────────────── */}
|
||||||
|
<AgentDetailPanel
|
||||||
agent={agent}
|
agent={agent}
|
||||||
agentName={agentDisplayName}
|
agentName={agentDisplayName}
|
||||||
disabled={thread.isLoading || isDemoMode}
|
disabled={thread.isLoading || isDemoMode}
|
||||||
|
|||||||
@@ -17,13 +17,11 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState";
|
import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState";
|
||||||
import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar";
|
import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar";
|
||||||
import { TocPanel } from "@/components/wiki/TocPanel";
|
|
||||||
import { WikiContent } from "@/components/wiki/WikiContent";
|
import { WikiContent } from "@/components/wiki/WikiContent";
|
||||||
import { WikiHeader } from "@/components/wiki/WikiHeader";
|
import { WikiHeader } from "@/components/wiki/WikiHeader";
|
||||||
import {
|
import {
|
||||||
buildKnowledgeTree,
|
buildKnowledgeTree,
|
||||||
downloadMarkdown,
|
downloadMarkdown,
|
||||||
extractToc,
|
|
||||||
findAncestorFolderIds,
|
findAncestorFolderIds,
|
||||||
} from "@/components/wiki/wikiUtils";
|
} from "@/components/wiki/wikiUtils";
|
||||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||||
@@ -41,6 +39,16 @@ import MarkdownRenderer, {
|
|||||||
type WikiLinkTarget,
|
type WikiLinkTarget,
|
||||||
} from "@/strategy-components/MarkdownRenderer";
|
} from "@/strategy-components/MarkdownRenderer";
|
||||||
|
|
||||||
|
const SIDEBAR_COLLAPSED_KEY = "wiki.sidebar.collapsed";
|
||||||
|
|
||||||
|
function readSidebarCollapsed(): boolean {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function useDebounced<T>(value: T, delay = 350): T {
|
function useDebounced<T>(value: T, delay = 350): T {
|
||||||
const [debounced, setDebounced] = useState(value);
|
const [debounced, setDebounced] = useState(value);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -461,6 +469,7 @@ export default function KnowledgeBasePage() {
|
|||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||||
const treeInitializedRef = useRef(false);
|
const treeInitializedRef = useRef(false);
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(readSidebarCollapsed);
|
||||||
|
|
||||||
// --- dialogs ---
|
// --- dialogs ---
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
@@ -469,9 +478,6 @@ export default function KnowledgeBasePage() {
|
|||||||
const [dedupOpen, setDedupOpen] = useState(false);
|
const [dedupOpen, setDedupOpen] = useState(false);
|
||||||
const [vaultPath, setVaultPath] = useState<string | null>(null);
|
const [vaultPath, setVaultPath] = useState<string | null>(null);
|
||||||
|
|
||||||
// --- reading state ---
|
|
||||||
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const params = useMemo(
|
const params = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
keyword: debouncedKeyword || undefined,
|
keyword: debouncedKeyword || undefined,
|
||||||
@@ -488,10 +494,6 @@ export default function KnowledgeBasePage() {
|
|||||||
|
|
||||||
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
|
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
|
||||||
const { data: wikiLinks } = useNoteWikilinks(noteId);
|
const { data: wikiLinks } = useNoteWikilinks(noteId);
|
||||||
const tocItems = useMemo(
|
|
||||||
() => (note ? extractToc(note.content_md) : []),
|
|
||||||
[note],
|
|
||||||
);
|
|
||||||
|
|
||||||
// First load: expand the root-level folders so the tree reads as a directory.
|
// First load: expand the root-level folders so the tree reads as a directory.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -529,11 +531,16 @@ export default function KnowledgeBasePage() {
|
|||||||
navigate(`/page/workspace/knowledge/${item.id}`);
|
navigate(`/page/workspace/knowledge/${item.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTocClick = (id: string) => {
|
const handleToggleSidebarCollapse = () => {
|
||||||
setActiveHeadingId(id);
|
setSidebarCollapsed((prev) => {
|
||||||
document
|
const next = !prev;
|
||||||
.getElementById(id)
|
try {
|
||||||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? "1" : "0");
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable (private mode) — state stays session-only.
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWikiLink = (link: WikiLinkTarget) => {
|
const handleWikiLink = (link: WikiLinkTarget) => {
|
||||||
@@ -555,7 +562,14 @@ export default function KnowledgeBasePage() {
|
|||||||
onCreate={() => setCreateOpen(true)}
|
onCreate={() => setCreateOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[300px_minmax(0,1fr)] xl:grid-cols-[300px_minmax(0,1fr)_280px]">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid min-h-0 flex-1 grid-cols-1",
|
||||||
|
sidebarCollapsed && !mobileSidebarOpen
|
||||||
|
? "md:grid-cols-[44px_minmax(0,1fr)]"
|
||||||
|
: "md:grid-cols-[288px_minmax(0,1fr)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{mobileSidebarOpen ? (
|
{mobileSidebarOpen ? (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 bg-black/30 md:hidden"
|
className="fixed inset-0 z-40 bg-black/30 md:hidden"
|
||||||
@@ -578,6 +592,8 @@ export default function KnowledgeBasePage() {
|
|||||||
onExportNote={downloadMarkdown}
|
onExportNote={downloadMarkdown}
|
||||||
total={data?.total ?? 0}
|
total={data?.total ?? 0}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
collapsed={sidebarCollapsed && !mobileSidebarOpen}
|
||||||
|
onToggleCollapse={handleToggleSidebarCollapse}
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:z-50 max-md:w-[300px] max-md:shadow-xl max-md:transition-transform max-md:duration-200",
|
"max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:z-50 max-md:w-[300px] max-md:shadow-xl max-md:transition-transform max-md:duration-200",
|
||||||
mobileSidebarOpen ? "max-md:translate-x-0" : "max-md:-translate-x-full",
|
mobileSidebarOpen ? "max-md:translate-x-0" : "max-md:-translate-x-full",
|
||||||
@@ -599,19 +615,10 @@ export default function KnowledgeBasePage() {
|
|||||||
) : (
|
) : (
|
||||||
<WikiContent
|
<WikiContent
|
||||||
note={note}
|
note={note}
|
||||||
toc={tocItems}
|
|
||||||
wikiLinks={wikiLinks}
|
wikiLinks={wikiLinks}
|
||||||
onWikiLinkClick={handleWikiLink}
|
onWikiLinkClick={handleWikiLink}
|
||||||
onActiveHeadingChange={setActiveHeadingId}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TocPanel
|
|
||||||
items={tocItems}
|
|
||||||
activeId={activeHeadingId}
|
|
||||||
onItemClick={handleTocClick}
|
|
||||||
className="hidden xl:block"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
|
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Loader2, Network, Tags } from "lucide-react";
|
import { Loader2, Network, RotateCcw, Tags, Undo2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -13,6 +13,8 @@ import { GraphHeader } from "@/components/knowledge-graph/GraphHeader";
|
|||||||
import { GraphSidebar } from "@/components/knowledge-graph/GraphSidebar";
|
import { GraphSidebar } from "@/components/knowledge-graph/GraphSidebar";
|
||||||
import { GraphTooltip } from "@/components/knowledge-graph/GraphTooltip";
|
import { GraphTooltip } from "@/components/knowledge-graph/GraphTooltip";
|
||||||
import type {
|
import type {
|
||||||
|
ExploreStep,
|
||||||
|
GraphMode,
|
||||||
LayoutType,
|
LayoutType,
|
||||||
NodeKind,
|
NodeKind,
|
||||||
TooltipState,
|
TooltipState,
|
||||||
@@ -20,6 +22,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
computeDegrees,
|
computeDegrees,
|
||||||
computeGraphStats,
|
computeGraphStats,
|
||||||
|
getEdgeId,
|
||||||
getEdgeLabel,
|
getEdgeLabel,
|
||||||
getNodeKind,
|
getNodeKind,
|
||||||
getNodeTitle,
|
getNodeTitle,
|
||||||
@@ -48,16 +51,23 @@ export default function KnowledgeGraphPage() {
|
|||||||
const [layoutType, setLayoutType] = useState<LayoutType>("force");
|
const [layoutType, setLayoutType] = useState<LayoutType>("force");
|
||||||
const [showLabels, setShowLabels] = useState(true);
|
const [showLabels, setShowLabels] = useState(true);
|
||||||
const [showSidebar, setShowSidebar] = useState(true);
|
const [showSidebar, setShowSidebar] = useState(true);
|
||||||
const [showDetail, setShowDetail] = useState(true);
|
const [showDetail, setShowDetail] = useState(false);
|
||||||
const [zoomLevel, setZoomLevel] = useState(1);
|
const [zoomLevel, setZoomLevel] = useState(1);
|
||||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||||
|
const [graphMode, setGraphMode] = useState<GraphMode>("explore");
|
||||||
|
const [exploreRootId, setExploreRootId] = useState<string | null>(null);
|
||||||
|
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(new Set());
|
||||||
|
const [visibleNodeIds, setVisibleNodeIds] = useState<Set<string>>(new Set());
|
||||||
|
const [visibleEdgeIds, setVisibleEdgeIds] = useState<Set<string>>(new Set());
|
||||||
|
const [nodeDepthMap, setNodeDepthMap] = useState<Map<string, number>>(new Map());
|
||||||
|
const [exploreHistory, setExploreHistory] = useState<ExploreStep[]>([]);
|
||||||
|
|
||||||
// Auto-collapse the side panels on narrow viewports.
|
// Auto-collapse the side panels on narrow viewports.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const detailQuery = window.matchMedia("(max-width: 1280px)");
|
const detailQuery = window.matchMedia("(max-width: 1280px)");
|
||||||
const sidebarQuery = window.matchMedia("(max-width: 900px)");
|
const sidebarQuery = window.matchMedia("(max-width: 900px)");
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
setShowDetail(!detailQuery.matches);
|
if (detailQuery.matches) setShowDetail(false);
|
||||||
setShowSidebar(!sidebarQuery.matches);
|
setShowSidebar(!sidebarQuery.matches);
|
||||||
};
|
};
|
||||||
apply();
|
apply();
|
||||||
@@ -122,6 +132,133 @@ export default function KnowledgeGraphPage() {
|
|||||||
);
|
);
|
||||||
}, [allEdges, filteredNodes, disabledEdgeLabels]);
|
}, [allEdges, filteredNodes, disabledEdgeLabels]);
|
||||||
|
|
||||||
|
const resetExplore = useCallback(() => {
|
||||||
|
setExploreRootId(null);
|
||||||
|
setExpandedNodeIds(new Set());
|
||||||
|
setVisibleNodeIds(new Set());
|
||||||
|
setVisibleEdgeIds(new Set());
|
||||||
|
setNodeDepthMap(new Map());
|
||||||
|
setExploreHistory([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rebuildExploreState = useCallback((history: ExploreStep[]) => {
|
||||||
|
const nextVisibleNodeIds = new Set<string>();
|
||||||
|
const nextVisibleEdgeIds = new Set<string>();
|
||||||
|
const nextNodeDepthMap = new Map<string, number>();
|
||||||
|
const nextExpandedNodeIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const step of history) {
|
||||||
|
for (const nodeId of step.addedNodeIds) {
|
||||||
|
nextVisibleNodeIds.add(nodeId);
|
||||||
|
if (!nextNodeDepthMap.has(nodeId)) nextNodeDepthMap.set(nodeId, step.depth);
|
||||||
|
}
|
||||||
|
for (const edgeId of step.addedEdgeIds) nextVisibleEdgeIds.add(edgeId);
|
||||||
|
if (step.depth > 0) nextExpandedNodeIds.add(step.nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisibleNodeIds(nextVisibleNodeIds);
|
||||||
|
setVisibleEdgeIds(nextVisibleEdgeIds);
|
||||||
|
setNodeDepthMap(nextNodeDepthMap);
|
||||||
|
setExpandedNodeIds(nextExpandedNodeIds);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startExploreFromNode = useCallback((nodeId: string) => {
|
||||||
|
setExploreRootId(nodeId);
|
||||||
|
setVisibleNodeIds(new Set([nodeId]));
|
||||||
|
setVisibleEdgeIds(new Set());
|
||||||
|
setExpandedNodeIds(new Set());
|
||||||
|
setNodeDepthMap(new Map([[nodeId, 0]]));
|
||||||
|
setExploreHistory([
|
||||||
|
{
|
||||||
|
nodeId,
|
||||||
|
depth: 0,
|
||||||
|
addedNodeIds: [nodeId],
|
||||||
|
addedEdgeIds: [],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
window.setTimeout(() => canvasRef.current?.focusNode(nodeId), 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const expandOneHop = useCallback(
|
||||||
|
(nodeId: string) => {
|
||||||
|
const currentDepth = nodeDepthMap.get(nodeId) ?? 0;
|
||||||
|
const nextDepth = currentDepth + 1;
|
||||||
|
const addedNodeIds: string[] = [];
|
||||||
|
const addedEdgeIds: string[] = [];
|
||||||
|
const nextVisibleNodeIds = new Set(visibleNodeIds);
|
||||||
|
const nextVisibleEdgeIds = new Set(visibleEdgeIds);
|
||||||
|
const nextNodeDepthMap = new Map(nodeDepthMap);
|
||||||
|
|
||||||
|
for (const edge of filteredEdges) {
|
||||||
|
if (edge.source !== nodeId && edge.target !== nodeId) continue;
|
||||||
|
const edgeId = getEdgeId(edge);
|
||||||
|
const neighborId = edge.source === nodeId ? edge.target : edge.source;
|
||||||
|
if (!nodeById.has(neighborId)) continue;
|
||||||
|
|
||||||
|
if (!nextVisibleEdgeIds.has(edgeId)) {
|
||||||
|
nextVisibleEdgeIds.add(edgeId);
|
||||||
|
addedEdgeIds.push(edgeId);
|
||||||
|
}
|
||||||
|
if (!nextVisibleNodeIds.has(neighborId)) {
|
||||||
|
nextVisibleNodeIds.add(neighborId);
|
||||||
|
nextNodeDepthMap.set(neighborId, nextDepth);
|
||||||
|
addedNodeIds.push(neighborId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextVisibleNodeIds.add(nodeId);
|
||||||
|
nextNodeDepthMap.set(nodeId, currentDepth);
|
||||||
|
setVisibleNodeIds(nextVisibleNodeIds);
|
||||||
|
setVisibleEdgeIds(nextVisibleEdgeIds);
|
||||||
|
setNodeDepthMap(nextNodeDepthMap);
|
||||||
|
setExpandedNodeIds((prev) => new Set(prev).add(nodeId));
|
||||||
|
setExploreHistory((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ nodeId, depth: nextDepth, addedNodeIds, addedEdgeIds, timestamp: Date.now() },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
[filteredEdges, nodeById, nodeDepthMap, visibleEdgeIds, visibleNodeIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const undoExploreStep = useCallback(() => {
|
||||||
|
if (exploreHistory.length <= 1) {
|
||||||
|
resetExplore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextHistory = exploreHistory.slice(0, -1);
|
||||||
|
setExploreHistory(nextHistory);
|
||||||
|
rebuildExploreState(nextHistory);
|
||||||
|
}, [exploreHistory, rebuildExploreState, resetExplore]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (graphMode === "explore") resetExplore();
|
||||||
|
}, [
|
||||||
|
disabledNodeKinds,
|
||||||
|
disabledEdgeLabels,
|
||||||
|
hiddenNodeIds,
|
||||||
|
searchKeyword,
|
||||||
|
resetExplore,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const displayedGraph = useMemo(() => {
|
||||||
|
if (graphMode === "full") return { nodes: filteredNodes, edges: filteredEdges };
|
||||||
|
if (!exploreRootId) return { nodes: filteredNodes, edges: [] };
|
||||||
|
return {
|
||||||
|
nodes: allNodes.filter((node) => visibleNodeIds.has(node.id)),
|
||||||
|
edges: allEdges.filter((edge) => visibleEdgeIds.has(getEdgeId(edge))),
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
allEdges,
|
||||||
|
allNodes,
|
||||||
|
exploreRootId,
|
||||||
|
filteredEdges,
|
||||||
|
filteredNodes,
|
||||||
|
graphMode,
|
||||||
|
visibleEdgeIds,
|
||||||
|
visibleNodeIds,
|
||||||
|
]);
|
||||||
|
|
||||||
const searchMatchIds = useMemo(() => {
|
const searchMatchIds = useMemo(() => {
|
||||||
const keyword = searchKeyword.trim().toLowerCase();
|
const keyword = searchKeyword.trim().toLowerCase();
|
||||||
if (!keyword) return null;
|
if (!keyword) return null;
|
||||||
@@ -146,7 +283,7 @@ export default function KnowledgeGraphPage() {
|
|||||||
if (!selectedNodeId) return [];
|
if (!selectedNodeId) return [];
|
||||||
const items: Array<{ node: (typeof allNodes)[number]; relation: string }> = [];
|
const items: Array<{ node: (typeof allNodes)[number]; relation: string }> = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const e of filteredEdges) {
|
for (const e of displayedGraph.edges) {
|
||||||
const otherId =
|
const otherId =
|
||||||
e.source === selectedNodeId
|
e.source === selectedNodeId
|
||||||
? e.target
|
? e.target
|
||||||
@@ -162,7 +299,7 @@ export default function KnowledgeGraphPage() {
|
|||||||
return items.sort(
|
return items.sort(
|
||||||
(a, b) => (degrees.get(b.node.id) ?? 0) - (degrees.get(a.node.id) ?? 0),
|
(a, b) => (degrees.get(b.node.id) ?? 0) - (degrees.get(a.node.id) ?? 0),
|
||||||
);
|
);
|
||||||
}, [selectedNodeId, filteredEdges, nodeById, degrees, allNodes]);
|
}, [selectedNodeId, displayedGraph.edges, nodeById, degrees, allNodes]);
|
||||||
|
|
||||||
// Note detail for note-type nodes (summary / tags / timestamps / status).
|
// Note detail for note-type nodes (summary / tags / timestamps / status).
|
||||||
const noteId =
|
const noteId =
|
||||||
@@ -172,14 +309,44 @@ export default function KnowledgeGraphPage() {
|
|||||||
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
|
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
|
||||||
|
|
||||||
// --------------------------------------------------------------- handlers
|
// --------------------------------------------------------------- handlers
|
||||||
const handleSelectNode = useCallback((id: string | null) => {
|
const handleSelectNode = useCallback(
|
||||||
setSelectedNodeId(id);
|
(id: string | null) => {
|
||||||
}, []);
|
if (!id) {
|
||||||
|
setSelectedNodeId(null);
|
||||||
|
setShowDetail(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const handlePickNode = useCallback((id: string) => {
|
setSelectedNodeId(id);
|
||||||
setSelectedNodeId(id);
|
setShowDetail(true);
|
||||||
canvasRef.current?.focusNode(id);
|
|
||||||
}, []);
|
if (graphMode !== "explore") return;
|
||||||
|
if (!exploreRootId) {
|
||||||
|
startExploreFromNode(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!visibleNodeIds.has(id)) return;
|
||||||
|
if (!expandedNodeIds.has(id)) expandOneHop(id);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
expandedNodeIds,
|
||||||
|
expandOneHop,
|
||||||
|
exploreRootId,
|
||||||
|
graphMode,
|
||||||
|
startExploreFromNode,
|
||||||
|
visibleNodeIds,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePickNode = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setSelectedNodeId(id);
|
||||||
|
setShowDetail(true);
|
||||||
|
if (graphMode === "explore" && !exploreRootId) startExploreFromNode(id);
|
||||||
|
canvasRef.current?.focusNode(id);
|
||||||
|
},
|
||||||
|
[exploreRootId, graphMode, startExploreFromNode],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSearchEnter = useCallback(() => {
|
const handleSearchEnter = useCallback(() => {
|
||||||
if (!searchMatchIds || searchMatchIds.size === 0) return;
|
if (!searchMatchIds || searchMatchIds.size === 0) return;
|
||||||
@@ -205,10 +372,14 @@ export default function KnowledgeGraphPage() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleHideNode = useCallback((id: string) => {
|
const handleHideNode = useCallback(
|
||||||
setHiddenNodeIds((prev) => new Set(prev).add(id));
|
(id: string) => {
|
||||||
setSelectedNodeId((current) => (current === id ? null : current));
|
setHiddenNodeIds((prev) => new Set(prev).add(id));
|
||||||
}, []);
|
setSelectedNodeId((current) => (current === id ? null : current));
|
||||||
|
if (selectedNodeId === id) setShowDetail(false);
|
||||||
|
},
|
||||||
|
[selectedNodeId],
|
||||||
|
);
|
||||||
|
|
||||||
const handleRestoreHidden = useCallback(() => {
|
const handleRestoreHidden = useCallback(() => {
|
||||||
setHiddenNodeIds(new Set());
|
setHiddenNodeIds(new Set());
|
||||||
@@ -238,6 +409,35 @@ export default function KnowledgeGraphPage() {
|
|||||||
[navigate],
|
[navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSetExploreRoot = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setGraphMode("explore");
|
||||||
|
startExploreFromNode(id);
|
||||||
|
setSelectedNodeId(id);
|
||||||
|
setShowDetail(true);
|
||||||
|
},
|
||||||
|
[startExploreFromNode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const exploreDepth = useMemo(() => {
|
||||||
|
let max = 0;
|
||||||
|
for (const depth of nodeDepthMap.values()) max = Math.max(max, depth);
|
||||||
|
return max;
|
||||||
|
}, [nodeDepthMap]);
|
||||||
|
|
||||||
|
const exploreRootTitle = exploreRootId
|
||||||
|
? getNodeTitle(nodeById.get(exploreRootId) ?? { id: exploreRootId })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const selectedExploreInfo =
|
||||||
|
graphMode === "explore" && selectedNodeId
|
||||||
|
? {
|
||||||
|
depth: nodeDepthMap.get(selectedNodeId) ?? null,
|
||||||
|
expanded: expandedNodeIds.has(selectedNodeId),
|
||||||
|
isRoot: selectedNodeId === exploreRootId,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
// ---------------------------------------------------------------- overlay
|
// ---------------------------------------------------------------- overlay
|
||||||
let overlay: React.ReactNode = null;
|
let overlay: React.ReactNode = null;
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -284,6 +484,50 @@ export default function KnowledgeGraphPage() {
|
|||||||
|
|
||||||
const toolbar = (
|
const toolbar = (
|
||||||
<>
|
<>
|
||||||
|
<div className="kg-floating-pill">
|
||||||
|
<span className="kg-pill-label">模式</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`kg-pill-button${graphMode === "explore" ? " kg-pill-button-active" : ""}`}
|
||||||
|
onClick={() => setGraphMode("explore")}
|
||||||
|
>
|
||||||
|
探索模式
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`kg-pill-button${graphMode === "full" ? " kg-pill-button-active" : ""}`}
|
||||||
|
onClick={() => setGraphMode("full")}
|
||||||
|
>
|
||||||
|
全量图谱
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{graphMode === "explore" ? (
|
||||||
|
<>
|
||||||
|
<div className="kg-floating-pill">
|
||||||
|
起点:{exploreRootTitle ? exploreRootTitle : "未选择"}
|
||||||
|
</div>
|
||||||
|
<div className="kg-floating-pill">
|
||||||
|
深度 {exploreDepth} · 已显示 {displayedGraph.nodes.length} 节点 /{" "}
|
||||||
|
{displayedGraph.edges.length} 关系
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="kg-explore-button"
|
||||||
|
disabled={exploreHistory.length === 0}
|
||||||
|
onClick={undoExploreStep}
|
||||||
|
>
|
||||||
|
<Undo2 size={13} /> 返回上一层
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="kg-explore-button"
|
||||||
|
disabled={!exploreRootId}
|
||||||
|
onClick={resetExplore}
|
||||||
|
>
|
||||||
|
<RotateCcw size={13} /> 重置探索
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<div className="kg-floating-pill">
|
<div className="kg-floating-pill">
|
||||||
<span className="kg-pill-label">布局</span>
|
<span className="kg-pill-label">布局</span>
|
||||||
<button
|
<button
|
||||||
@@ -313,16 +557,23 @@ export default function KnowledgeGraphPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hint =
|
||||||
|
graphMode === "explore" ? (
|
||||||
|
<div className="kg-canvas-hint">
|
||||||
|
{!exploreRootId
|
||||||
|
? "点击任意节点作为起点,开始逐层探索知识关系。"
|
||||||
|
: "点击已显示节点,可继续展开它的一跳邻居。已展开的节点和路径会保留。"}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="kg-page" ref={pageRef}>
|
<div className="kg-page" ref={pageRef}>
|
||||||
<GraphHeader
|
<GraphHeader
|
||||||
nodeCount={filteredNodes.length}
|
nodeCount={displayedGraph.nodes.length}
|
||||||
edgeCount={filteredEdges.length}
|
edgeCount={displayedGraph.edges.length}
|
||||||
totalNodes={allNodes.length}
|
totalNodes={allNodes.length}
|
||||||
totalEdges={allEdges.length}
|
totalEdges={allEdges.length}
|
||||||
refreshing={isFetching}
|
refreshing={isFetching}
|
||||||
showSidebar={showSidebar}
|
|
||||||
showDetail={showDetail}
|
|
||||||
onBack={() => navigate("/page/workspace/knowledge")}
|
onBack={() => navigate("/page/workspace/knowledge")}
|
||||||
onRelayout={() => canvasRef.current?.relayout()}
|
onRelayout={() => canvasRef.current?.relayout()}
|
||||||
onFitView={() => canvasRef.current?.fitView()}
|
onFitView={() => canvasRef.current?.fitView()}
|
||||||
@@ -331,8 +582,6 @@ export default function KnowledgeGraphPage() {
|
|||||||
onExport={() => canvasRef.current?.exportImage()}
|
onExport={() => canvasRef.current?.exportImage()}
|
||||||
onFullscreen={handleFullscreen}
|
onFullscreen={handleFullscreen}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onToggleSidebar={() => setShowSidebar((v) => !v)}
|
|
||||||
onToggleDetail={() => setShowDetail((v) => !v)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -361,24 +610,56 @@ export default function KnowledgeGraphPage() {
|
|||||||
onToggleEdgeLabel={handleToggleEdgeLabel}
|
onToggleEdgeLabel={handleToggleEdgeLabel}
|
||||||
onRestoreHidden={handleRestoreHidden}
|
onRestoreHidden={handleRestoreHidden}
|
||||||
onPickNode={handlePickNode}
|
onPickNode={handlePickNode}
|
||||||
|
onClose={() => setShowSidebar(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<GraphCanvas
|
<div className="kg-canvas-shell">
|
||||||
ref={canvasRef}
|
{!showSidebar ? (
|
||||||
nodes={filteredNodes}
|
<button
|
||||||
edges={filteredEdges}
|
type="button"
|
||||||
degrees={degrees}
|
className="kg-side-rail-toggle kg-side-rail-toggle-left"
|
||||||
selectedNodeId={selectedNodeId}
|
onClick={() => setShowSidebar(true)}
|
||||||
searchMatchIds={searchMatchIds}
|
>
|
||||||
layoutType={layoutType}
|
筛选
|
||||||
showLabels={showLabels}
|
</button>
|
||||||
onSelectNode={handleSelectNode}
|
) : null}
|
||||||
onTooltip={setTooltip}
|
<GraphCanvas
|
||||||
onZoomChange={setZoomLevel}
|
ref={canvasRef}
|
||||||
toolbar={toolbar}
|
nodes={displayedGraph.nodes}
|
||||||
overlay={overlay}
|
edges={displayedGraph.edges}
|
||||||
/>
|
degrees={degrees}
|
||||||
|
selectedNodeId={selectedNodeId}
|
||||||
|
searchMatchIds={searchMatchIds}
|
||||||
|
layoutType={layoutType}
|
||||||
|
showLabels={showLabels}
|
||||||
|
onSelectNode={handleSelectNode}
|
||||||
|
onTooltip={setTooltip}
|
||||||
|
onZoomChange={setZoomLevel}
|
||||||
|
explore={
|
||||||
|
graphMode === "explore"
|
||||||
|
? {
|
||||||
|
preview: !exploreRootId,
|
||||||
|
rootId: exploreRootId,
|
||||||
|
nodeDepthMap,
|
||||||
|
expandedNodeIds,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
toolbar={toolbar}
|
||||||
|
hint={hint}
|
||||||
|
overlay={overlay}
|
||||||
|
/>
|
||||||
|
{!showDetail ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="kg-side-rail-toggle kg-side-rail-toggle-right"
|
||||||
|
onClick={() => setShowDetail(true)}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{showDetail ? (
|
{showDetail ? (
|
||||||
<GraphDetailPanel
|
<GraphDetailPanel
|
||||||
@@ -387,10 +668,14 @@ export default function KnowledgeGraphPage() {
|
|||||||
neighbors={selectedNeighbors}
|
neighbors={selectedNeighbors}
|
||||||
note={noteId ? note : undefined}
|
note={noteId ? note : undefined}
|
||||||
noteLoading={Boolean(noteId) && noteLoading}
|
noteLoading={Boolean(noteId) && noteLoading}
|
||||||
|
explore={selectedExploreInfo}
|
||||||
onViewSource={handleViewSource}
|
onViewSource={handleViewSource}
|
||||||
onLocate={(id) => canvasRef.current?.focusNode(id)}
|
onLocate={(id) => canvasRef.current?.focusNode(id)}
|
||||||
onHide={handleHideNode}
|
onHide={handleHideNode}
|
||||||
onPickNeighbor={handlePickNode}
|
onPickNeighbor={handlePickNode}
|
||||||
|
onExpandNode={expandOneHop}
|
||||||
|
onSetExploreRoot={handleSetExploreRoot}
|
||||||
|
onClose={() => setShowDetail(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -400,8 +685,8 @@ export default function KnowledgeGraphPage() {
|
|||||||
edgeLabels={[...edgeLabelCounts.keys()]}
|
edgeLabels={[...edgeLabelCounts.keys()]}
|
||||||
zoomLevel={zoomLevel}
|
zoomLevel={zoomLevel}
|
||||||
selectedTitle={selectedNode ? getNodeTitle(selectedNode) : null}
|
selectedTitle={selectedNode ? getNodeTitle(selectedNode) : null}
|
||||||
visibleNodes={filteredNodes.length}
|
visibleNodes={displayedGraph.nodes.length}
|
||||||
visibleEdges={filteredEdges.length}
|
visibleEdges={displayedGraph.edges.length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GraphTooltip tooltip={tooltip} />
|
<GraphTooltip tooltip={tooltip} />
|
||||||
|
|||||||
Reference in new issue
Block a user