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
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, PlusSquare } from "lucide-react";
|
||||
import { BotIcon, MessageCircleQuestionIcon, PlusSquare } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
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 { 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 { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
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 { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { normalizeAgentQuestions, useAgent } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
@@ -28,6 +28,7 @@ import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function AgentChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
@@ -61,6 +62,11 @@ export default function AgentChatPage() {
|
||||
const agentDisplayName = desensitize(
|
||||
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 } =
|
||||
useThreadChat();
|
||||
@@ -225,33 +231,70 @@ export default function AgentChatPage() {
|
||||
<AgentWelcome
|
||||
agent={agent}
|
||||
agentName={agentDisplayName}
|
||||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
||||
disabled={isDemoMode}
|
||||
className="mb-7"
|
||||
/>
|
||||
<div className="w-full max-w-(--container-width-md)">
|
||||
<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}
|
||||
{/* 常用问题 sit as a list to the LEFT of the input box (not as
|
||||
floating pills above it). On narrow screens the list stacks
|
||||
above the input instead of squeezing it. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-3 sm:flex-row sm:items-end sm:gap-4",
|
||||
commonQuestions.length > 0
|
||||
? "max-w-(--container-width-lg)"
|
||||
: "max-w-(--container-width-md)",
|
||||
)}
|
||||
>
|
||||
{commonQuestions.length > 0 && (
|
||||
<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">
|
||||
<MessageCircleQuestionIcon className="size-3.5" />
|
||||
常用问题
|
||||
</div>
|
||||
<div className="flex max-h-44 flex-col gap-1.5 overflow-y-auto">
|
||||
{commonQuestions.map((question, index) => (
|
||||
<button
|
||||
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 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>
|
||||
) : (
|
||||
@@ -304,8 +347,9 @@ export default function AgentChatPage() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* ── Right: read-only agent profile sidebar ───────────────────── */}
|
||||
<AgentInfoSidebar
|
||||
{/* ── Right: agent detail panel (profile + inline editing for
|
||||
admins / owners; read-only for everyone else) ───────────────── */}
|
||||
<AgentDetailPanel
|
||||
agent={agent}
|
||||
agentName={agentDisplayName}
|
||||
disabled={thread.isLoading || isDemoMode}
|
||||
|
||||
@@ -17,13 +17,11 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState";
|
||||
import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar";
|
||||
import { TocPanel } from "@/components/wiki/TocPanel";
|
||||
import { WikiContent } from "@/components/wiki/WikiContent";
|
||||
import { WikiHeader } from "@/components/wiki/WikiHeader";
|
||||
import {
|
||||
buildKnowledgeTree,
|
||||
downloadMarkdown,
|
||||
extractToc,
|
||||
findAncestorFolderIds,
|
||||
} from "@/components/wiki/wikiUtils";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
@@ -41,6 +39,16 @@ import MarkdownRenderer, {
|
||||
type WikiLinkTarget,
|
||||
} 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 {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
@@ -461,6 +469,7 @@ export default function KnowledgeBasePage() {
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const treeInitializedRef = useRef(false);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(readSidebarCollapsed);
|
||||
|
||||
// --- dialogs ---
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
@@ -469,9 +478,6 @@ export default function KnowledgeBasePage() {
|
||||
const [dedupOpen, setDedupOpen] = useState(false);
|
||||
const [vaultPath, setVaultPath] = useState<string | null>(null);
|
||||
|
||||
// --- reading state ---
|
||||
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
||||
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
keyword: debouncedKeyword || undefined,
|
||||
@@ -488,10 +494,6 @@ export default function KnowledgeBasePage() {
|
||||
|
||||
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
|
||||
const { data: wikiLinks } = useNoteWikilinks(noteId);
|
||||
const tocItems = useMemo(
|
||||
() => (note ? extractToc(note.content_md) : []),
|
||||
[note],
|
||||
);
|
||||
|
||||
// First load: expand the root-level folders so the tree reads as a directory.
|
||||
useEffect(() => {
|
||||
@@ -529,11 +531,16 @@ export default function KnowledgeBasePage() {
|
||||
navigate(`/page/workspace/knowledge/${item.id}`);
|
||||
};
|
||||
|
||||
const handleTocClick = (id: string) => {
|
||||
setActiveHeadingId(id);
|
||||
document
|
||||
.getElementById(id)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
const handleToggleSidebarCollapse = () => {
|
||||
setSidebarCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? "1" : "0");
|
||||
} catch {
|
||||
// localStorage unavailable (private mode) — state stays session-only.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleWikiLink = (link: WikiLinkTarget) => {
|
||||
@@ -555,7 +562,14 @@ export default function KnowledgeBasePage() {
|
||||
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 ? (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/30 md:hidden"
|
||||
@@ -578,6 +592,8 @@ export default function KnowledgeBasePage() {
|
||||
onExportNote={downloadMarkdown}
|
||||
total={data?.total ?? 0}
|
||||
isLoading={isLoading}
|
||||
collapsed={sidebarCollapsed && !mobileSidebarOpen}
|
||||
onToggleCollapse={handleToggleSidebarCollapse}
|
||||
className={cn(
|
||||
"max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:z-50 max-md:w-[300px] max-md:shadow-xl max-md:transition-transform max-md:duration-200",
|
||||
mobileSidebarOpen ? "max-md:translate-x-0" : "max-md:-translate-x-full",
|
||||
@@ -599,19 +615,10 @@ export default function KnowledgeBasePage() {
|
||||
) : (
|
||||
<WikiContent
|
||||
note={note}
|
||||
toc={tocItems}
|
||||
wikiLinks={wikiLinks}
|
||||
onWikiLinkClick={handleWikiLink}
|
||||
onActiveHeadingChange={setActiveHeadingId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TocPanel
|
||||
items={tocItems}
|
||||
activeId={activeHeadingId}
|
||||
onItemClick={handleTocClick}
|
||||
className="hidden xl:block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
@@ -13,6 +13,8 @@ import { GraphHeader } from "@/components/knowledge-graph/GraphHeader";
|
||||
import { GraphSidebar } from "@/components/knowledge-graph/GraphSidebar";
|
||||
import { GraphTooltip } from "@/components/knowledge-graph/GraphTooltip";
|
||||
import type {
|
||||
ExploreStep,
|
||||
GraphMode,
|
||||
LayoutType,
|
||||
NodeKind,
|
||||
TooltipState,
|
||||
@@ -20,6 +22,7 @@ import type {
|
||||
import {
|
||||
computeDegrees,
|
||||
computeGraphStats,
|
||||
getEdgeId,
|
||||
getEdgeLabel,
|
||||
getNodeKind,
|
||||
getNodeTitle,
|
||||
@@ -48,16 +51,23 @@ export default function KnowledgeGraphPage() {
|
||||
const [layoutType, setLayoutType] = useState<LayoutType>("force");
|
||||
const [showLabels, setShowLabels] = useState(true);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
const [showDetail, setShowDetail] = useState(true);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
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.
|
||||
useEffect(() => {
|
||||
const detailQuery = window.matchMedia("(max-width: 1280px)");
|
||||
const sidebarQuery = window.matchMedia("(max-width: 900px)");
|
||||
const apply = () => {
|
||||
setShowDetail(!detailQuery.matches);
|
||||
if (detailQuery.matches) setShowDetail(false);
|
||||
setShowSidebar(!sidebarQuery.matches);
|
||||
};
|
||||
apply();
|
||||
@@ -122,6 +132,133 @@ export default function KnowledgeGraphPage() {
|
||||
);
|
||||
}, [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 keyword = searchKeyword.trim().toLowerCase();
|
||||
if (!keyword) return null;
|
||||
@@ -146,7 +283,7 @@ export default function KnowledgeGraphPage() {
|
||||
if (!selectedNodeId) return [];
|
||||
const items: Array<{ node: (typeof allNodes)[number]; relation: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
for (const e of filteredEdges) {
|
||||
for (const e of displayedGraph.edges) {
|
||||
const otherId =
|
||||
e.source === selectedNodeId
|
||||
? e.target
|
||||
@@ -162,7 +299,7 @@ export default function KnowledgeGraphPage() {
|
||||
return items.sort(
|
||||
(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).
|
||||
const noteId =
|
||||
@@ -172,14 +309,44 @@ export default function KnowledgeGraphPage() {
|
||||
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
|
||||
|
||||
// --------------------------------------------------------------- handlers
|
||||
const handleSelectNode = useCallback((id: string | null) => {
|
||||
setSelectedNodeId(id);
|
||||
}, []);
|
||||
const handleSelectNode = useCallback(
|
||||
(id: string | null) => {
|
||||
if (!id) {
|
||||
setSelectedNodeId(null);
|
||||
setShowDetail(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePickNode = useCallback((id: string) => {
|
||||
setSelectedNodeId(id);
|
||||
canvasRef.current?.focusNode(id);
|
||||
}, []);
|
||||
setSelectedNodeId(id);
|
||||
setShowDetail(true);
|
||||
|
||||
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(() => {
|
||||
if (!searchMatchIds || searchMatchIds.size === 0) return;
|
||||
@@ -205,10 +372,14 @@ export default function KnowledgeGraphPage() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleHideNode = useCallback((id: string) => {
|
||||
setHiddenNodeIds((prev) => new Set(prev).add(id));
|
||||
setSelectedNodeId((current) => (current === id ? null : current));
|
||||
}, []);
|
||||
const handleHideNode = useCallback(
|
||||
(id: string) => {
|
||||
setHiddenNodeIds((prev) => new Set(prev).add(id));
|
||||
setSelectedNodeId((current) => (current === id ? null : current));
|
||||
if (selectedNodeId === id) setShowDetail(false);
|
||||
},
|
||||
[selectedNodeId],
|
||||
);
|
||||
|
||||
const handleRestoreHidden = useCallback(() => {
|
||||
setHiddenNodeIds(new Set());
|
||||
@@ -238,6 +409,35 @@ export default function KnowledgeGraphPage() {
|
||||
[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
|
||||
let overlay: React.ReactNode = null;
|
||||
if (isLoading) {
|
||||
@@ -284,6 +484,50 @@ export default function KnowledgeGraphPage() {
|
||||
|
||||
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">
|
||||
<span className="kg-pill-label">布局</span>
|
||||
<button
|
||||
@@ -313,16 +557,23 @@ export default function KnowledgeGraphPage() {
|
||||
</>
|
||||
);
|
||||
|
||||
const hint =
|
||||
graphMode === "explore" ? (
|
||||
<div className="kg-canvas-hint">
|
||||
{!exploreRootId
|
||||
? "点击任意节点作为起点,开始逐层探索知识关系。"
|
||||
: "点击已显示节点,可继续展开它的一跳邻居。已展开的节点和路径会保留。"}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="kg-page" ref={pageRef}>
|
||||
<GraphHeader
|
||||
nodeCount={filteredNodes.length}
|
||||
edgeCount={filteredEdges.length}
|
||||
nodeCount={displayedGraph.nodes.length}
|
||||
edgeCount={displayedGraph.edges.length}
|
||||
totalNodes={allNodes.length}
|
||||
totalEdges={allEdges.length}
|
||||
refreshing={isFetching}
|
||||
showSidebar={showSidebar}
|
||||
showDetail={showDetail}
|
||||
onBack={() => navigate("/page/workspace/knowledge")}
|
||||
onRelayout={() => canvasRef.current?.relayout()}
|
||||
onFitView={() => canvasRef.current?.fitView()}
|
||||
@@ -331,8 +582,6 @@ export default function KnowledgeGraphPage() {
|
||||
onExport={() => canvasRef.current?.exportImage()}
|
||||
onFullscreen={handleFullscreen}
|
||||
onRefresh={handleRefresh}
|
||||
onToggleSidebar={() => setShowSidebar((v) => !v)}
|
||||
onToggleDetail={() => setShowDetail((v) => !v)}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -361,24 +610,56 @@ export default function KnowledgeGraphPage() {
|
||||
onToggleEdgeLabel={handleToggleEdgeLabel}
|
||||
onRestoreHidden={handleRestoreHidden}
|
||||
onPickNode={handlePickNode}
|
||||
onClose={() => setShowSidebar(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<GraphCanvas
|
||||
ref={canvasRef}
|
||||
nodes={filteredNodes}
|
||||
edges={filteredEdges}
|
||||
degrees={degrees}
|
||||
selectedNodeId={selectedNodeId}
|
||||
searchMatchIds={searchMatchIds}
|
||||
layoutType={layoutType}
|
||||
showLabels={showLabels}
|
||||
onSelectNode={handleSelectNode}
|
||||
onTooltip={setTooltip}
|
||||
onZoomChange={setZoomLevel}
|
||||
toolbar={toolbar}
|
||||
overlay={overlay}
|
||||
/>
|
||||
<div className="kg-canvas-shell">
|
||||
{!showSidebar ? (
|
||||
<button
|
||||
type="button"
|
||||
className="kg-side-rail-toggle kg-side-rail-toggle-left"
|
||||
onClick={() => setShowSidebar(true)}
|
||||
>
|
||||
筛选
|
||||
</button>
|
||||
) : null}
|
||||
<GraphCanvas
|
||||
ref={canvasRef}
|
||||
nodes={displayedGraph.nodes}
|
||||
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 ? (
|
||||
<GraphDetailPanel
|
||||
@@ -387,10 +668,14 @@ export default function KnowledgeGraphPage() {
|
||||
neighbors={selectedNeighbors}
|
||||
note={noteId ? note : undefined}
|
||||
noteLoading={Boolean(noteId) && noteLoading}
|
||||
explore={selectedExploreInfo}
|
||||
onViewSource={handleViewSource}
|
||||
onLocate={(id) => canvasRef.current?.focusNode(id)}
|
||||
onHide={handleHideNode}
|
||||
onPickNeighbor={handlePickNode}
|
||||
onExpandNode={expandOneHop}
|
||||
onSetExploreRoot={handleSetExploreRoot}
|
||||
onClose={() => setShowDetail(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -400,8 +685,8 @@ export default function KnowledgeGraphPage() {
|
||||
edgeLabels={[...edgeLabelCounts.keys()]}
|
||||
zoomLevel={zoomLevel}
|
||||
selectedTitle={selectedNode ? getNodeTitle(selectedNode) : null}
|
||||
visibleNodes={filteredNodes.length}
|
||||
visibleEdges={filteredEdges.length}
|
||||
visibleNodes={displayedGraph.nodes.length}
|
||||
visibleEdges={displayedGraph.edges.length}
|
||||
/>
|
||||
|
||||
<GraphTooltip tooltip={tooltip} />
|
||||
|
||||
Reference in new issue
Block a user