feat: improve wiki document and artifact views

This commit is contained in:
吉鑫升 committed 2026-08-24 14:01:10 +08:00
1 parent 3785345353
commit 49babb6d25
23 files changed
+1728 -329

No files matched your search

@@ -0,0 +1,226 @@
import {
ChevronDownIcon,
ChevronRightIcon,
FolderIcon,
FolderOpenIcon,
RefreshCwIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { artifactPathname, artifactRelativePath } from "@/core/artifacts/listing";
import { getFileIcon, getFileName } from "@/core/utils/files";
import { cn } from "@/lib/utils";
type DirectoryNode = {
name: string;
path: string;
directories: DirectoryNode[];
files: Array<{ artifact: string; name: string; relativePath: string }>;
};
function buildDirectoryTree(artifacts: string[]): DirectoryNode {
const root: DirectoryNode = {
name: "生成文件",
path: "",
directories: [],
files: [],
};
for (const artifact of artifacts) {
const relativePath = artifactRelativePath(artifact);
const parts = relativePath.split("/").filter(Boolean);
if (parts.length === 0) continue;
let current = root;
for (const directoryName of parts.slice(0, -1)) {
let child = current.directories.find((node) => node.name === directoryName);
if (!child) {
child = {
name: directoryName,
path: current.path ? `${current.path}/${directoryName}` : directoryName,
directories: [],
files: [],
};
current.directories.push(child);
}
current = child;
}
current.files.push({
artifact,
name: parts.at(-1) ?? getFileName(artifact),
relativePath,
});
}
const sort = (node: DirectoryNode) => {
node.directories.sort((left, right) =>
left.name.localeCompare(right.name, "zh-CN"),
);
node.files.sort((left, right) =>
left.name.localeCompare(right.name, "zh-CN"),
);
node.directories.forEach(sort);
};
sort(root);
return root;
}
function DirectoryBranch({
node,
depth,
collapsedDirectories,
onToggleDirectory,
selectedArtifact,
onSelect,
}: {
node: DirectoryNode;
depth: number;
collapsedDirectories: Set<string>;
onToggleDirectory: (path: string) => void;
selectedArtifact: string | null;
onSelect: (artifact: string) => void;
}) {
return (
<>
{node.directories.map((directory) => {
const collapsed = collapsedDirectories.has(directory.path);
return (
<div key={directory.path}>
<button
type="button"
className="text-muted-foreground hover:bg-muted/70 hover:text-foreground flex h-8 w-full items-center gap-1.5 rounded-md pr-2 text-left text-xs"
style={{ paddingLeft: `${8 + depth * 14}px` }}
onClick={() => onToggleDirectory(directory.path)}
title={directory.path}
>
{collapsed ? (
<ChevronRightIcon className="size-3.5 shrink-0" />
) : (
<ChevronDownIcon className="size-3.5 shrink-0" />
)}
{collapsed ? (
<FolderIcon className="size-4 shrink-0 text-amber-500" />
) : (
<FolderOpenIcon className="size-4 shrink-0 text-amber-500" />
)}
<span className="truncate">{directory.name}</span>
</button>
{!collapsed && (
<DirectoryBranch
node={directory}
depth={depth + 1}
collapsedDirectories={collapsedDirectories}
onToggleDirectory={onToggleDirectory}
selectedArtifact={selectedArtifact}
onSelect={onSelect}
/>
)}
</div>
);
})}
{node.files.map((file) => {
const selected =
!!selectedArtifact &&
artifactPathname(selectedArtifact) === artifactPathname(file.artifact);
return (
<button
key={file.relativePath}
type="button"
className={cn(
"hover:bg-muted/70 flex min-h-8 w-full items-center gap-2 rounded-md py-1 pr-2 text-left text-xs",
selected
? "bg-primary/10 text-primary font-medium"
: "text-foreground/85",
)}
style={{ paddingLeft: `${27 + depth * 14}px` }}
onClick={() => onSelect(file.artifact)}
title={file.relativePath}
aria-current={selected ? "page" : undefined}
>
<span className="shrink-0">{getFileIcon(file.artifact, "size-4")}</span>
<span className="truncate">{file.name}</span>
</button>
);
})}
</>
);
}
export function ArtifactDirectory({
className,
artifacts,
selectedArtifact,
onSelect,
onRefresh,
refreshing = false,
}: {
className?: string;
artifacts: string[];
selectedArtifact: string | null;
onSelect: (artifact: string) => void;
onRefresh?: () => void;
refreshing?: boolean;
}) {
const tree = useMemo(() => buildDirectoryTree(artifacts), [artifacts]);
const [collapsedDirectories, setCollapsedDirectories] = useState<Set<string>>(
() => new Set(),
);
const toggleDirectory = (path: string) => {
setCollapsedDirectories((current) => {
const next = new Set(current);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
};
return (
<aside
className={cn(
"bg-muted/20 flex min-h-0 w-48 shrink-0 flex-col border-r xl:w-56",
className,
)}
aria-label="生成文件目录"
>
<div className="flex h-12 shrink-0 items-center justify-between gap-2 border-b px-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold">生成文件</div>
<div className="text-muted-foreground text-[11px]">{artifacts.length} 个文件</div>
</div>
{onRefresh && (
<Button
type="button"
size="icon-sm"
variant="ghost"
className="size-7 shrink-0"
onClick={onRefresh}
disabled={refreshing}
title="刷新文件目录"
aria-label="刷新文件目录"
>
<RefreshCwIcon className={cn("size-3.5", refreshing && "animate-spin")} />
</Button>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{artifacts.length > 0 ? (
<DirectoryBranch
node={tree}
depth={0}
collapsedDirectories={collapsedDirectories}
onToggleDirectory={toggleDirectory}
selectedArtifact={selectedArtifact}
onSelect={onSelect}
/>
) : (
<div className="text-muted-foreground px-2 py-8 text-center text-xs leading-5">
生成的文件会显示在这里
</div>
)}
</div>
</aside>
);
}
@@ -52,11 +52,17 @@ interface ArtifactsProviderProps {
children: ReactNode;
}
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => {
setArtifactsState(Array.isArray(next) ? next : []);
}, []);
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => {
const normalized = Array.isArray(next) ? next : [];
setArtifactsState((current) => {
const unchanged =
current.length === normalized.length &&
current.every((artifact, index) => artifact === normalized[index]);
return unchanged ? current : normalized;
});
}, []);
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
@@ -99,38 +105,43 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
[sidebar],
);
const deselect = useCallback(() => {
const deselect = useCallback(() => {
setSelectedArtifact(null);
setSelectedArtifactKey(null);
setAutoSelect(true);
setOpenState(false);
suppressedAutoOpenKeyRef.current = null;
}, []);
const value: ArtifactsContextType = {
artifacts,
setArtifacts,
open,
autoOpen,
autoSelect,
setOpen: (isOpen: boolean) => {
if (!isOpen && autoOpen) {
setAutoOpen(false);
setAutoSelect(false);
}
const setOpen = useCallback(
(isOpen: boolean) => {
if (!isOpen && autoOpen) {
setAutoOpen(false);
setAutoSelect(false);
}
if (!isOpen) {
suppressedAutoOpenKeyRef.current = selectedArtifactKey;
}
setOpenState(isOpen);
},
[autoOpen, selectedArtifactKey],
);
const value: ArtifactsContextType = {
artifacts,
setArtifacts,
open,
autoOpen,
autoSelect,
setOpen,
selectedArtifact,
selectedArtifactKey,
select,
openStreamedArtifact,
deselect,
};
};
return (
<ArtifactsContext.Provider value={value}>
@@ -1,4 +1,5 @@
export * from "./artifact-file-detail";
export * from "./artifact-file-list";
export * from "./artifact-file-detail";
export * from "./artifact-directory";
export * from "./artifact-file-list";
export * from "./artifact-trigger";
export * from "./context";
@@ -1,4 +1,5 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useQuery } from "@tanstack/react-query";
import { FilesIcon, XIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -13,6 +14,10 @@ import {
} from "@/components/ui/resizable";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { apiFetch } from "@/core/api";
import {
listThreadArtifacts,
mergeArtifactPaths,
} from "@/core/artifacts/listing";
import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
@@ -32,6 +37,7 @@ import { cn } from "@/lib/utils";
import {
ArtifactFileDetail,
ArtifactDirectory,
ArtifactFileList,
useArtifacts,
} from "../artifacts";
@@ -40,6 +46,7 @@ import { SkillDisplaySources } from "../skill-display-sources";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 };
const DIRECTORY_OPEN_MODE = { chat: 48, artifacts: 52 };
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
type BackendReferenceSource = Parameters<typeof SkillDisplaySources>[0]["sources"][number];
@@ -78,7 +85,7 @@ const ChatBox: React.FC<{
threadId,
disableArtifacts = false,
}) => {
const { thread } = useThread();
const { isMock, thread } = useThread();
const pathname = usePathname();
const threadIdRef = useRef(threadId);
const artifactsCountRef = useRef(0);
@@ -92,6 +99,8 @@ const ChatBox: React.FC<{
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
const [settings] = useLocalSettings();
const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false;
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
// Frontend RAG modes write their own hidden source tool messages. They use
@@ -110,6 +119,52 @@ const ChatBox: React.FC<{
referencesEnabled && !hasFrontendRag;
const { skills } = useSkills();
const canScanArtifactDirectory =
artifactDirectoryModeEnabled &&
!disableArtifacts &&
!isMock &&
!!threadId &&
threadId !== "new";
const artifactListingQuery = useQuery({
queryKey: ["artifact-directory", threadId],
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
enabled: canScanArtifactDirectory,
refetchInterval: thread.isLoading ? 1_500 : false,
retry: false,
});
const checkpointArtifacts = useMemo(
() => (disableArtifacts ? [] : (thread.values.artifacts ?? [])),
[disableArtifacts, thread.values.artifacts],
);
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
const availableArtifacts = useMemo(
() =>
artifactDirectoryModeEnabled
? mergeArtifactPaths(
checkpointArtifacts,
(artifactListingQuery.data?.files ?? []).map((file) => file.path),
)
: checkpointArtifacts,
[
artifactDirectoryModeEnabled,
artifactListingQuery.data?.files,
checkpointArtifacts,
],
);
useEffect(() => {
if (!canScanArtifactDirectory || thread.isLoading) return;
// Run once after streaming settles as well as when checkpoint artifacts
// change, so files written without present_files are not missed by the
// last polling tick.
void artifactListingQuery.refetch();
}, [
artifactListingQuery.refetch,
canScanArtifactDirectory,
checkpointArtifactSignature,
thread.isLoading,
]);
const {
artifacts,
open: artifactsOpen,
@@ -302,7 +357,8 @@ const ChatBox: React.FC<{
};
}, []);
useEffect(() => {
if (threadIdRef.current !== threadId) {
const threadChanged = threadIdRef.current !== threadId;
if (threadChanged) {
threadIdRef.current = threadId;
artifactsCountRef.current = 0;
deselect();
@@ -316,15 +372,21 @@ const ChatBox: React.FC<{
}
// Update artifacts from the current thread
const nextArtifacts = disableArtifacts ? [] : (thread.values.artifacts ?? []);
const nextArtifacts = availableArtifacts;
const hadArtifacts = artifactsCountRef.current > 0;
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
artifactsCountRef.current = nextArtifacts.length;
setArtifacts(nextArtifacts);
const artifactsChanged =
artifacts.length !== nextArtifacts.length ||
artifacts.some((artifact, index) => artifact !== nextArtifacts[index]);
if (artifactsChanged) {
setArtifacts(nextArtifacts);
}
if (
!disableArtifacts &&
(hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) &&
!selectedArtifact
nextArtifacts.length > 0 &&
((artifactDirectoryModeEnabled && threadChanged) ||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
) {
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
setArtifactsOpen(true);
@@ -351,15 +413,41 @@ const ChatBox: React.FC<{
}
}, [
threadId,
artifactDirectoryModeEnabled,
autoSelectFirstArtifact,
deselect,
selectArtifact,
selectedArtifact,
artifacts,
setArtifacts,
thread.values.artifacts,
availableArtifacts,
disableArtifacts,
]);
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
useEffect(() => {
const wasEnabled = directoryModeWasEnabledRef.current;
directoryModeWasEnabledRef.current = artifactDirectoryModeEnabled;
if (
wasEnabled ||
!artifactDirectoryModeEnabled ||
availableArtifacts.length === 0
) {
return;
}
if (!selectedArtifact) {
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!);
}
setArtifactsOpen(true);
setSideTab("document");
}, [
artifactDirectoryModeEnabled,
availableArtifacts,
selectArtifact,
selectedArtifact,
setArtifactsOpen,
]);
const artifactPanelOpen = useMemo(() => {
if (disableArtifacts) {
return false;
@@ -370,7 +458,10 @@ const ChatBox: React.FC<{
return artifactsOpen;
}, [artifactsOpen, artifacts, disableArtifacts]);
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
const showTabs = !!selectedArtifact && hasCitationReferences;
const showTabs =
hasCitationReferences &&
(!!selectedArtifact ||
(artifactDirectoryModeEnabled && artifacts.length > 0));
useEffect(() => {
if (selectedArtifact) {
@@ -390,12 +481,103 @@ const ChatBox: React.FC<{
useEffect(() => {
if (layoutRef.current) {
if (sidePanelOpen) {
layoutRef.current.setLayout(OPEN_MODE);
layoutRef.current.setLayout(
artifactDirectoryModeEnabled && artifactPanelOpen
? DIRECTORY_OPEN_MODE
: OPEN_MODE,
);
} else {
layoutRef.current.setLayout(CLOSE_MODE);
}
}
}, [sidePanelOpen]);
}, [artifactDirectoryModeEnabled, artifactPanelOpen, sidePanelOpen]);
const artifactDocumentPanel = artifactDirectoryModeEnabled ? (
<div className="flex size-full min-w-0 overflow-hidden rounded-lg border">
<ArtifactDirectory
artifacts={artifacts}
selectedArtifact={selectedArtifact}
onSelect={(artifact) => {
selectArtifact(artifact);
setArtifactsOpen(true);
}}
onRefresh={
canScanArtifactDirectory
? () => void artifactListingQuery.refetch()
: undefined
}
refreshing={artifactListingQuery.isFetching}
/>
<div className="relative min-w-0 flex-1 overflow-hidden">
{selectedArtifact ? (
<ArtifactFileDetail
key={selectedArtifactKey ?? selectedArtifact}
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
allowVirtualMarkdownEdit
/>
) : (
<>
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => setArtifactsOpen(false)}
>
<XIcon />
</Button>
</div>
<ConversationEmptyState
icon={<FilesIcon />}
title="请选择文件"
description="从左侧目录中选择一个文件进行查看"
/>
</>
)}
</div>
</div>
) : selectedArtifact ? (
<ArtifactFileDetail
key={selectedArtifactKey ?? selectedArtifact}
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
allowVirtualMarkdownEdit
/>
) : (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => setArtifactsOpen(false)}
>
<XIcon />
</Button>
</div>
{artifacts.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="No artifact selected"
description="Select an artifact to view its details"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={artifacts}
threadId={threadId}
/>
</main>
</div>
)}
</div>
);
if (disableArtifacts) {
return (
@@ -455,13 +637,7 @@ const ChatBox: React.FC<{
</Button>
</div>
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
<ArtifactFileDetail
key={selectedArtifactKey ?? selectedArtifact}
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
allowVirtualMarkdownEdit
/>
{artifactDocumentPanel}
</TabsContent>
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
<ReferencePanel
@@ -470,14 +646,8 @@ const ChatBox: React.FC<{
/>
</TabsContent>
</Tabs>
) : selectedArtifact ? (
<ArtifactFileDetail
key={selectedArtifactKey ?? selectedArtifact}
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
allowVirtualMarkdownEdit
/>
) : artifactPanelOpen ? (
artifactDocumentPanel
) : hasCitationReferences ? (
<div className="relative size-full overflow-hidden">
<div className="absolute top-1 right-1 z-30">
@@ -497,41 +667,7 @@ const ChatBox: React.FC<{
sources={referenceSources}
/>
</div>
) : (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</div>
{thread.values.artifacts?.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="No artifact selected"
description="Select an artifact to view its details"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={thread.values.artifacts ?? []}
threadId={threadId}
/>
</main>
</div>
)}
</div>
)}
) : null}
</div>
</ResizablePanel>
</ResizablePanelGroup>
@@ -4,8 +4,9 @@ import type { ChatStatus } from "ai";
import {
BrainCircuitIcon,
BookMarkedIcon,
CheckIcon,
FileTextIcon,
CheckIcon,
FileTextIcon,
FolderTreeIcon,
GraduationCapIcon,
LightbulbIcon,
PaperclipIcon,
@@ -368,12 +369,20 @@ export function InputBox({
[onContextChange, context],
);
const handleWritingModeToggle = useCallback(() => {
const handleWritingModeToggle = useCallback(() => {
onContextChange?.({
...context,
writing_mode: !context.writing_mode,
});
}, [context, onContextChange]);
}, [context, onContextChange]);
const artifactDirectoryModeEnabled =
localSettings.display.artifactDirectoryModeEnabled ?? false;
const handleArtifactDirectoryModeToggle = useCallback(() => {
setLocalSettings("display", {
artifactDirectoryModeEnabled: !artifactDirectoryModeEnabled,
});
}, [artifactDirectoryModeEnabled, setLocalSettings]);
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
const handleMemoryInjectionToggle = useCallback(() => {
@@ -736,7 +745,7 @@ export function InputBox({
)}
/>
)}
{!frontendRagActive && <Tooltip
{!frontendRagActive && <Tooltip
content={
context.writing_mode
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
@@ -760,9 +769,38 @@ export function InputBox({
)}
<FileTextIcon className="size-3" />
<span className="text-xs font-normal">写作</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && <Tooltip
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && <Tooltip
content={
artifactDirectoryModeEnabled
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
: "开启文件模式:在右侧目录中浏览当前对话生成的全部文件"
}
>
<PromptInputButton
type="button"
aria-pressed={artifactDirectoryModeEnabled}
disabled={disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
artifactDirectoryModeEnabled
? "border border-sky-300 bg-sky-50 text-sky-700 hover:bg-sky-100 dark:border-sky-600 dark:bg-sky-950/45 dark:text-sky-200"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleArtifactDirectoryModeToggle}
>
{artifactDirectoryModeEnabled && (
<CheckIcon className="size-3 text-sky-700 dark:text-sky-200" />
)}
<FolderTreeIcon className="size-3" />
<span className="text-xs font-normal">文件</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && <Tooltip
content={
memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆"
@@ -262,7 +262,7 @@ function ToolCall({
? null
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
null);
const displayItems =
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
@@ -270,15 +270,21 @@ function ToolCall({
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
: [];
: [];
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
const wikiSearchLabel = name === "llmwiki_search"
? wikiQuery
? `Wiki 文档检索 "${wikiQuery}"`
: "Wiki 文档检索"
: null;
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
key={id}
label={
displayMatch?.display.mode === "citation" || !displayMatch
wikiSearchLabel || (displayMatch?.display.mode === "citation" || !displayMatch
? "检索参考来源"
: `获取 ${displayItems.length} 条结果`
: `获取 ${displayItems.length} 条结果`)
}
icon={SearchIcon}
>
@@ -303,7 +309,7 @@ function ToolCall({
mode: "citation" as const,
}));
return (
<ChainOfThoughtStep key={id} label="检索参考来源" icon={SearchIcon}>
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
<DisplayResultsList results={defaultItems} />
</ChainOfThoughtStep>
);
@@ -1,4 +1,4 @@
import { ExternalLinkIcon, FileTextIcon } from "lucide-react";
import { BookOpenTextIcon, ChevronRightIcon, ExternalLinkIcon, FileTextIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -12,9 +12,11 @@ import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import { useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { reasoningPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
export function SkillDisplaySources({
highlightedReferenceId,
@@ -63,8 +65,20 @@ export function SourceCard({
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
const snippet = compactReferenceText(content).slice(0, 280);
const paragraphs = segmentReferenceText(content);
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
const matchedHeading = isWikiSource
? String(
(source.raw?.matched_heading as string | undefined) ||
(source.raw?.matched_sections as Array<{ heading?: string }> | undefined)?.find(
(section) => section.heading,
)?.heading ||
"",
).trim()
: "";
const summary = isWikiSource && typeof source.raw?.summary === "string"
? source.raw.summary
: "";
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
const score =
typeof source.score === "number"
? source.score <= 1
@@ -73,16 +87,22 @@ export function SourceCard({
: null;
const meta = [
source.source,
previewTarget?.wikiSlug && source.raw?.matched_sections
? `Wiki 章节 ${String((source.raw.matched_sections as Array<{ heading?: string }>)[0]?.heading || "")}`
: null,
isWikiSource ? "检索对象:Wiki 文档" : null,
matchedHeading ? `命中章节:${matchedHeading}` : null,
source.author ? `作者 ${source.author}` : null,
source.type && source.type !== "result" ? source.type : null,
!isWikiSource && source.type && source.type !== "result" ? source.type : null,
source.time,
score ? `相关度 ${score}` : null,
].filter(Boolean);
const isCitation = source.mode === "citation";
const showReferenceNumber = isCitation;
const openSource = () => {
if (previewTarget) {
setDetailOpen(true);
} else {
setRelatedOpen(true);
}
};
return (
<>
@@ -97,11 +117,11 @@ export function SourceCard({
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
"cursor-pointer",
)}
onClick={() => setRelatedOpen(true)}
onClick={openSource}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setRelatedOpen(true);
openSource();
}
}}
>
@@ -131,11 +151,16 @@ export function SourceCard({
</p>
)}
</div>
{source.url && (
{(source.url || isWikiSource) && (
previewTarget ? (
<span className="text-muted-foreground bg-muted mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium">
<FileTextIcon className="size-3.5" />
{previewTarget.wikiSlug ? "Wiki 页面" : "知识库文档"}
<span className={cn(
"mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium",
isWikiSource
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
: "bg-muted text-muted-foreground",
)}>
{isWikiSource ? <BookOpenTextIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />}
{isWikiSource ? "Wiki 文档" : "知识库文档"}
</span>
) : (
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
@@ -143,19 +168,9 @@ export function SourceCard({
)}
</div>
{previewTarget && (
<div className="mt-3 flex justify-start border-t pt-2">
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-primary h-7 px-1.5 text-xs"
onClick={(event) => {
event.stopPropagation();
setDetailOpen(true);
}}
>
<ExternalLinkIcon className="mr-1.5 size-3.5" />
{previewTarget.wikiSlug ? "查看 Wiki" : "查看详情"}
</Button>
<div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground">
<span>{isWikiSource ? "点击卡片查看完整 Wiki" : "点击卡片查看详情"}</span>
<ChevronRightIcon className="size-3.5 transition-transform group-hover:translate-x-0.5" />
</div>
)}
</div>
@@ -168,7 +183,7 @@ export function SourceCard({
onOpenChange={setDetailOpen}
/>
)}
<Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
{!previewTarget && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
<DialogHeader>
<div className="flex items-start justify-between gap-3 pr-8">
@@ -205,13 +220,23 @@ export function SourceCard({
)}
</div>
</DialogHeader>
{paragraphs.length > 0 ? (
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
{paragraphs.map((paragraph, index) => (
<p key={index} className="whitespace-pre-wrap">
{paragraph}
</p>
))}
{content ? (
<div className="max-h-[62vh] overflow-y-auto rounded-xl border bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
{isWikiSource && (
<div className="mb-4 flex items-center gap-2 border-b pb-3 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<BookOpenTextIcon className="size-4" />
本次检索命中的 Wiki 文档内容
</div>
)}
<article className="mx-auto max-w-3xl rounded-lg bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
<MarkdownContent
className="max-w-none"
content={content}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
</article>
</div>
) : null}
{source.url && !previewTarget && (
@@ -220,7 +245,7 @@ export function SourceCard({
</div>
)}
</DialogContent>
</Dialog>
</Dialog>}
</>
);
}
@@ -249,44 +274,18 @@ function compactReferenceText(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
function segmentReferenceText(text: string): string[] {
const explicit = text
.split(/\n{2,}/)
.map((item) => item.trim())
.filter(Boolean);
const base = explicit.length > 1 ? explicit : splitLongParagraph(text);
return base.slice(0, 80);
}
function splitLongParagraph(text: string): string[] {
const normalized = text
.split(/\n+/)
.map((item) => item.trim())
.filter(Boolean)
.join("\n");
if (normalized.length <= 360) {
return normalized ? [normalized] : [];
}
const sentences = normalized
.split(/(?<=[。!?!?;;])\s*/)
.map((item) => item.trim())
.filter(Boolean);
if (sentences.length <= 1) {
return normalized.match(/[\s\S]{1,360}/g) ?? [];
}
const paragraphs: string[] = [];
let current = "";
for (const sentence of sentences) {
if ((current + sentence).length > 360 && current) {
paragraphs.push(current.trim());
current = "";
}
current += sentence;
}
if (current.trim()) {
paragraphs.push(current.trim());
}
return paragraphs;
function plainTextReferencePreview(text: string): string {
return compactReferenceText(
text
.replace(/^---[\s\S]*?---\s*/m, "")
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
.replace(/\[\[([^\]]+)\]\]/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+[.)]\s+/gm, "")
.replace(/[>*_`~|]/g, " "),
);
}
+113
View File
@@ -0,0 +1,113 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export interface ArtifactListingFile {
path: string;
name: string;
sizeBytes: number;
mimeType: string | null;
modifiedAt: string;
}
export interface ArtifactListing {
threadId: string;
files: ArtifactListingFile[];
}
/**
* Scan the thread's outputs directory instead of relying only on artifacts
* explicitly surfaced by `present_files`. This is used by file mode so files
* written by any tool are still discoverable from the right-hand directory.
*/
export async function listThreadArtifacts(
threadId: string,
signal?: AbortSignal,
): Promise<ArtifactListing> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts`,
{ method: "GET", signal },
);
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(
`Failed to list artifacts (${response.status})${detail ? `: ${detail}` : ""}`,
);
}
const data = (await response.json()) as {
thread_id?: string;
files?: Array<{
path: string;
name: string;
size_bytes: number;
mime_type: string | null;
modified_at: string;
}>;
};
return {
threadId: data.thread_id ?? threadId,
files: (data.files ?? []).map((file) => ({
path: file.path,
name: file.name,
sizeBytes: file.size_bytes,
mimeType: file.mime_type,
modifiedAt: file.modified_at,
})),
};
}
/** Return the generated file path represented by a real or streaming artifact. */
export function artifactPathname(artifact: string): string {
const trimmed = artifact.trim();
if (!trimmed.startsWith("write-file:")) {
return trimmed.replace(/^\/+/, "");
}
try {
return decodeURIComponent(new URL(trimmed).pathname).replace(/^\/+/, "");
} catch {
return trimmed;
}
}
export function artifactRelativePath(artifact: string): string {
const pathname = artifactPathname(artifact);
return pathname.replace(/^mnt\/user-data\/outputs\/?/i, "") || pathname;
}
/**
* Keep the checkpoint order, append files found only on disk from old to new,
* and replace a completed streaming URL with its durable path.
*/
export function mergeArtifactPaths(
checkpointArtifacts: string[],
discoveredArtifacts: string[],
): string[] {
const merged: string[] = [];
const indexByPath = new Map<string, number>();
for (const artifact of checkpointArtifacts) {
const key = artifactPathname(artifact);
if (!key || indexByPath.has(key)) continue;
indexByPath.set(key, merged.length);
merged.push(artifact);
}
// The listing endpoint returns newest first. Append in chronological order
// so the last item remains the newest artifact for the existing auto-open.
for (const artifact of [...discoveredArtifacts].reverse()) {
const key = artifactPathname(artifact);
if (!key) continue;
const existingIndex = indexByPath.get(key);
if (existingIndex !== undefined) {
if (merged[existingIndex]?.startsWith("write-file:")) {
merged[existingIndex] = artifact;
}
continue;
}
indexByPath.set(key, merged.length);
merged.push(artifact);
}
return merged;
}
+1 -1
View File
@@ -456,7 +456,7 @@ export const enUS: Translations = {
lightDescription: "Bright palette with higher contrast for daytime.",
darkDescription: "Dim palette that reduces glare for focus.",
lightblueDescription: "A crisp light palette built around blue.",
lightgreenDescription: "A crisp light palette built around WeKnora green.",
lightgreenDescription: "A crisp light palette built around fresh green tones.",
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
languageTitle: "Language",
+1 -1
View File
@@ -437,7 +437,7 @@ export const zhCN: Translations = {
lightDescription: "更明亮的配色,适合日间使用。",
darkDescription: "更暗的配色,减少眩光方便专注。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
lightgreenDescription: "采用清新绿色的浅色配色。",
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
languageTitle: "语言",
+22 -1
View File
@@ -95,6 +95,25 @@ export function buildWeKnoraSourceIframeUrl(target: WeKnoraSourceTarget): string
return `/#/page/workspace/knowledge/${encodeURIComponent(target.knowledgeBaseId)}${query ? `?${query}` : ""}`;
}
/**
* Lightweight native reader for one Wiki page. Unlike the knowledge-base
* detail route this does not create a WeKnora iframe session.
*/
export function buildWeKnoraWikiPageUrl(
knowledgeBaseId: string,
wikiSlug: string,
): string {
return `/#${buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)}`;
}
export function buildWeKnoraWikiPagePath(
knowledgeBaseId: string,
wikiSlug: string,
): string {
const params = new URLSearchParams({ wikiSlug });
return `/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view?${params.toString()}`;
}
export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): string {
const recUuid = source.recUuid || source.rec_uuid || source.url || "";
if (!recUuid) return "";
@@ -109,7 +128,9 @@ export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): stri
export function openKnowledgeSource(source: KnowledgeSourceLike): boolean {
const weknoraTarget = resolveWeKnoraSourceTarget(source);
const href = weknoraTarget
? buildWeKnoraSourceIframeUrl(weknoraTarget)
? weknoraTarget.wikiSlug
? buildWeKnoraWikiPageUrl(weknoraTarget.knowledgeBaseId, weknoraTarget.wikiSlug)
: buildWeKnoraSourceIframeUrl(weknoraTarget)
: buildLegacyKnowledgeSourceUrl(source);
if (!href) return false;
window.open(href, "_blank", "noopener,noreferrer");
@@ -0,0 +1,45 @@
import { buildWeKnoraWikiPageUrl } from "@/core/llmwiki/source-links";
export function stripWikiFrontmatter(value: string) {
return value.replace(/^\s*---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, "").trim();
}
export function safeWikiFileName(value: string) {
return value.replace(/[\\/:*?"<>|\r\n]+/g, "_").replace(/\s+/g, " ").trim() || "wiki-page";
}
export function exportableWikiMarkdown(title: string, summary: string, content: string) {
const body = content.trim();
const hasTitle = /^#\s+/m.test(body);
return [hasTitle ? "" : `# ${title}`, summary && !body.includes(summary) ? `> ${summary}` : "", body]
.filter(Boolean)
.join("\n\n");
}
export function wikiLinkMarkdown(markdown: string, knowledgeBaseId: string) {
const link = (slugValue: string, labelValue?: string) => {
const slug = slugValue.trim();
const fallbackLabel = slug.split("/").filter(Boolean).at(-1) || slug;
const label = (labelValue || fallbackLabel).trim().replace(/\]/g, "\\]");
return `[${label}](${buildWeKnoraWikiPageUrl(knowledgeBaseId, slug)})`;
};
return markdown
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, (_match, slug, label) => link(slug, label))
.replace(/\[\[([^\]\s]+)\s+([^\]]+)\]\]/g, (_match, slug, label) => link(slug, label))
.replace(/\[\[([^\]]+)\]\]/g, (_match, slug) => link(slug));
}
export function renderableWikiMarkdown(
title: string,
content: string,
knowledgeBaseId: string,
) {
const lines = content.trim().split("\n");
if (lines[0]?.replace(/^#+\s*/, "").trim() === title.trim()) {
lines[0] = `# ${title}`;
}
return wikiLinkMarkdown(
exportableWikiMarkdown(title, "", lines.join("\n")),
knowledgeBaseId,
);
}
+146
View File
@@ -0,0 +1,146 @@
import type { LlmWikiWikiPage } from "@/strategy-components/api/llmwiki";
export type WikiTreeNode = {
key: string;
label: string;
page: LlmWikiWikiPage | null;
children: WikiTreeNode[];
count: number;
};
export type WikiTree = {
roots: WikiTreeNode[];
parentKeysBySlug: Map<string, string[]>;
};
function pageDirectoryParts(page: LlmWikiWikiPage): string[] {
const wikiPath = (page.wiki_path || "").split("/").map((item) => item.trim()).filter(Boolean);
if (wikiPath.length > 2) return wikiPath.slice(1, -1);
const category = page.category_path?.filter(Boolean) || [];
if (category.length > 0) return category;
const parts = (page.slug || "").split("/").filter(Boolean);
return parts.length > 1 ? parts.slice(0, -1) : ["未分类"];
}
export function filterWikiPagesWithAncestors(
pages: LlmWikiWikiPage[],
query: string,
): LlmWikiWikiPage[] {
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (words.length === 0) return pages;
const matched = pages.filter((page) => {
const haystack = [
page.title,
page.slug,
page.summary,
page.page_type,
page.parent_slug,
...(page.aliases || []),
...(page.category_path || []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return words.every((word) => haystack.includes(word));
});
const bySlug = new Map(pages.map((page) => [page.slug, page]));
const result = new Map<string, LlmWikiWikiPage>();
for (const page of matched) {
let current: LlmWikiWikiPage | undefined = page;
const visited = new Set<string>();
while (current?.slug && !visited.has(current.slug)) {
visited.add(current.slug);
result.set(current.slug, current);
current = current.parent_slug ? bySlug.get(current.parent_slug) : undefined;
}
}
return Array.from(result.values());
}
function attachNode(parent: WikiTreeNode | null, roots: WikiTreeNode[], node: WikiTreeNode) {
const siblings = parent ? parent.children : roots;
if (!siblings.some((item) => item.key === node.key)) siblings.push(node);
}
function sortWikiNodes(nodes: WikiTreeNode[]) {
nodes.sort((left, right) => {
if (!!left.children.length !== !!right.children.length) return left.children.length ? -1 : 1;
return left.label.localeCompare(right.label, "zh-Hans-CN");
});
nodes.forEach((node) => sortWikiNodes(node.children));
}
function countPages(node: WikiTreeNode): number {
node.count = (node.page ? 1 : 0) + node.children.reduce((sum, child) => sum + countPages(child), 0);
return node.count;
}
export function buildWikiTree(pages: LlmWikiWikiPage[]): WikiTree {
const roots: WikiTreeNode[] = [];
const pageNodes = new Map<string, WikiTreeNode>();
const directoryNodes = new Map<string, WikiTreeNode>();
const parentKeysBySlug = new Map<string, string[]>();
for (const page of pages) {
if (!page.slug) continue;
pageNodes.set(page.slug, {
key: `page:${page.slug}`,
label: page.title || page.slug,
page,
children: [],
count: 1,
});
}
const directoryNode = (path: string[]) => {
const key = `dir:${path.join("/")}`;
const existing = directoryNodes.get(key);
if (existing) return existing;
const node: WikiTreeNode = {
key,
label: path[path.length - 1] || "未分类",
page: null,
children: [],
count: 0,
};
directoryNodes.set(key, node);
return node;
};
for (const page of pages) {
const node = pageNodes.get(page.slug);
if (!node) continue;
const ancestorKeys: string[] = [];
const parentSlug = page.parent_slug?.trim();
if (parentSlug && pageNodes.has(parentSlug)) {
const parent = pageNodes.get(parentSlug)!;
attachNode(parent, roots, node);
ancestorKeys.push(parent.key);
let current = parent.page?.parent_slug ? pageNodes.get(parent.page.parent_slug) : undefined;
const seen = new Set<string>([parent.key]);
while (current && !seen.has(current.key)) {
seen.add(current.key);
ancestorKeys.unshift(current.key);
current = current.page?.parent_slug ? pageNodes.get(current.page.parent_slug) : undefined;
}
parentKeysBySlug.set(page.slug, ancestorKeys);
continue;
}
let parent: WikiTreeNode | null = null;
const path: string[] = [];
for (const part of pageDirectoryParts(page)) {
path.push(part);
const directory = directoryNode(path);
attachNode(parent, roots, directory);
parent = directory;
ancestorKeys.push(directory.key);
}
attachNode(parent, roots, node);
parentKeysBySlug.set(page.slug, ancestorKeys);
}
roots.forEach(countPages);
sortWikiNodes(roots);
return { roots, parentKeysBySlug };
}
+11 -5
View File
@@ -4,10 +4,11 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
notification: {
enabled: true,
},
display: {
streamSpeedMeterEnabled: true,
ragModeEnabled: false,
contextCompressionEnabled: false,
display: {
streamSpeedMeterEnabled: true,
ragModeEnabled: false,
artifactDirectoryModeEnabled: false,
contextCompressionEnabled: false,
citationCleanupWords: [],
fontScale: 1,
},
@@ -43,7 +44,12 @@ export interface LocalSettings {
* Global switch for skill result display adapters. When disabled, chat
* messages render tool results with the legacy generic search UI only.
*/
ragModeEnabled: boolean;
ragModeEnabled: boolean;
/**
* When enabled, the artifact panel scans every generated output and keeps
* a directory tree visible beside the selected file preview.
*/
artifactDirectoryModeEnabled: boolean;
/**
* Per-user switch for context compression (上下文压缩). When enabled, long
* conversations are summarized transiently before the model call to save
@@ -1,6 +1,6 @@
import { Loader2, Settings2 } from "lucide-react";
import { useState } from "react";
import { Navigate } from "react-router-dom";
import { Navigate, useParams, useSearchParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
@@ -10,6 +10,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { getStoredAuth } from "@/core/auth";
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import WeKnoraKnowledgeWorkspace, {
ConnectionSettings,
@@ -66,7 +67,14 @@ export default function LlmWikiKnowledgePage({
}: {
mode?: LlmWikiKnowledgePageMode;
}) {
const { noteId = "" } = useParams();
const [searchParams] = useSearchParams();
const runtime = useLlmWikiRuntime();
const wikiSlug = searchParams.get("wikiSlug")?.trim() || "";
if (mode === "detail" && noteId && wikiSlug) {
return <Navigate to={buildWeKnoraWikiPagePath(noteId, wikiSlug)} replace />;
}
if (runtime.isLoading) {
return <RuntimeLoading />;
@@ -40,6 +40,7 @@ import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
import ScheduledTasksPage from './ScheduledTasksPage';
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
import WeKnoraWikiPageView from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView';
import MarkdownDocxEditorPage from './MarkdownDocxEditorPage';
import LlmMetricsPage from './LlmMetricsPage';
import CheckpointMigrationPage from './CheckpointMigrationPage';
@@ -658,6 +659,14 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="knowledge/:noteId/wiki/view"
element={
<WorkspaceLayout {...layoutProps}>
<WeKnoraWikiPageView />
</WorkspaceLayout>
}
/>
<Route
path="knowledge/:noteId/wiki"
element={
@@ -175,16 +175,38 @@ export interface LlmWikiSourceContext {
}
export interface LlmWikiSearchResult {
chunk_id: string;
kind: "wiki_page";
id: string;
wiki_page_id: string;
wiki_slug: string;
content: string;
knowledge_id: string;
knowledge_base_id: string;
knowledge_base_name: string;
title: string;
filename: string;
summary?: string;
page_type?: string;
page_version?: number;
updated_at?: string | null;
matched_heading?: string | null;
matched_sections?: Array<{
section_index: number;
heading?: string | null;
content: string;
score: number;
}>;
score: number;
chunk_index: number;
url: string;
retrieval_mode?: "vector" | "wiki_api";
}
export interface LlmWikiSearchResponse {
query: string;
mode: "vector" | "wiki_api" | "mixed";
results: LlmWikiSearchResult[];
knowledge_base_count: number;
partial: boolean;
warnings: string[];
fallback_knowledge_base_ids: string[];
}
export interface LlmWikiWeKnoraEmbedSession {
@@ -219,6 +241,10 @@ export interface LlmWikiWikiPageUpdate {
wiki_path?: string;
}
function userFacingKnowledgeMessage(value: string): string {
return value.replace(/weknora/gi, "知识服务");
}
async function readJson<T>(response: Response, context: string): Promise<T> {
if (!response.ok) {
const payload = await response.json().catch(() => null as unknown);
@@ -230,7 +256,7 @@ async function readJson<T>(response: Response, context: string): Promise<T> {
: rawDetail
? String(rawDetail)
: `${context}失败(${response.status})`;
throw new Error(detail);
throw new Error(userFacingKnowledgeMessage(detail));
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
@@ -286,7 +312,7 @@ export async function createLlmWikiWeKnoraEmbedSession(
`${API()}/knowledge-bases/${encodeURIComponent(id)}/weknora/session?tab=${encodeURIComponent(tab)}`,
{ method: "POST" },
),
"创建 WeKnora 详情会话",
"创建知识库详情会话",
);
}
@@ -634,8 +660,8 @@ export async function searchLlmWiki(input: {
query: string;
scope: LlmWikiScope;
knowledge_base_ids?: string[];
}): Promise<LlmWikiSearchResult[]> {
const payload = await readJson<{ results: LlmWikiSearchResult[] }>(
}): Promise<LlmWikiSearchResponse> {
return readJson<LlmWikiSearchResponse>(
await apiFetch(`${API()}/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -643,7 +669,6 @@ export async function searchLlmWiki(input: {
}),
"检索知识库",
);
return payload.results ?? [];
}
export async function createLlmWikiConversationDeposit(input: {
@@ -666,7 +691,7 @@ export async function createLlmWikiConversationDeposit(input: {
}
export async function getLlmWikiSettings(): Promise<LlmWikiSettings> {
return readJson(await apiFetch(SETTINGS_API()), "读取 WeKnora 连接配置");
return readJson(await apiFetch(SETTINGS_API()), "读取知识服务连接配置");
}
export async function saveLlmWikiSettings(input: {
@@ -680,7 +705,7 @@ export async function saveLlmWikiSettings(input: {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
"保存 WeKnora 连接配置",
"保存知识服务连接配置",
);
}
@@ -700,7 +725,7 @@ export async function testLlmWikiConnection(
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_base_url: apiBaseUrl }),
}),
"测试 WeKnora 连接",
"测试知识服务连接",
);
}
@@ -1,4 +1,4 @@
import { AlertCircle, Edit3, FileText, Loader2, RefreshCw, Trash2, Upload } from "lucide-react";
import { AlertCircle, Edit3, FileText, Loader2, RefreshCw, Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
@@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownCodeEditor } from "@/components/wiki/MarkdownCodeEditor";
import { getBackendBaseURL } from "@/core/config";
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
import {
createLlmWikiWeKnoraEmbedSession,
getLlmWikiSourceContext,
@@ -100,14 +101,11 @@ export function WeKnoraKnowledgeDetailPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const frameRef = useRef<HTMLIFrameElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [frameSrc, setFrameSrc] = useState("");
const [frameVersion, setFrameVersion] = useState(0);
const [frameError, setFrameError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const runtime = useLlmWikiRuntime();
const details = useLlmWikiKnowledgeBase(knowledgeBaseId || null, !!knowledgeBaseId);
const actions = useLlmWikiActions();
const detailsReady = !!details.data;
const requestedTab = searchParams.get("tab");
@@ -120,12 +118,15 @@ export function WeKnoraKnowledgeDetailPage() {
const sourceChunkId = searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
const documentId = searchParams.get("documentId") || "";
const wikiSlug = searchParams.get("wikiSlug") || "";
const nativeWikiPageUrl = knowledgeBaseId && wikiSlug && preferredTab === "wiki"
? buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)
: "";
useEffect(() => {
let cancelled = false;
setFrameSrc("");
setFrameError(null);
if (!knowledgeBaseId || runtime.data?.provider !== "weknora" || !detailsReady) {
if (nativeWikiPageUrl || !knowledgeBaseId || runtime.data?.provider !== "weknora" || !detailsReady) {
return () => {
cancelled = true;
};
@@ -158,7 +159,7 @@ export function WeKnoraKnowledgeDetailPage() {
return () => {
cancelled = true;
};
}, [detailsReady, documentId, knowledgeBaseId, preferredTab, runtime.data?.provider, sourceChunkId, wikiSlug]);
}, [detailsReady, documentId, knowledgeBaseId, nativeWikiPageUrl, preferredTab, runtime.data?.provider, sourceChunkId, wikiSlug]);
useEffect(() => {
if (!frameSrc) return;
@@ -178,6 +179,10 @@ export function WeKnoraKnowledgeDetailPage() {
};
}, [frameSrc]);
if (nativeWikiPageUrl) {
return <Navigate to={nativeWikiPageUrl} replace />;
}
if (runtime.isLoading || details.isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
@@ -225,53 +230,15 @@ export function WeKnoraKnowledgeDetailPage() {
);
}
const canUpload = details.data.can_write && !details.data.is_conversation_deposit;
const canWrite = details.data.can_write && !details.data.is_conversation_deposit;
const canEditCurrentWikiArticle =
canUpload && !!details.data.indexing_strategy?.wiki_enabled && !!wikiSlug;
const canManageWiki = canUpload && !!details.data.indexing_strategy?.wiki_enabled;
canWrite && !!details.data.indexing_strategy?.wiki_enabled && !!wikiSlug;
const canManageWiki = canWrite && !!details.data.indexing_strategy?.wiki_enabled;
return (
<div className="flex h-full min-h-0 flex-col bg-background">
{(canUpload || canEditCurrentWikiArticle) && (
{canManageWiki && (
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
const files = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = "";
if (files.length === 0) return;
void (async () => {
setUploading(true);
const failed: string[] = [];
try {
for (const file of files) {
try {
await actions.upload.mutateAsync({ id: knowledgeBaseId, file });
} catch {
failed.push(file.name);
}
}
const succeeded = files.length - failed.length;
if (succeeded > 0) {
await details.refetch();
setFrameVersion((value) => value + 1);
}
if (failed.length === 0) {
toast.success(`已上传 ${succeeded} 个文件,正在处理`);
} else if (succeeded > 0) {
toast.warning(`上传完成:成功 ${succeeded} 个,失败 ${failed.length} 个`);
} else {
toast.error("文件上传失败");
}
} finally {
setUploading(false);
}
})();
}}
/>
<div className="min-w-0 flex-1">
{canEditCurrentWikiArticle && (
<CurrentWikiArticleActions
@@ -288,7 +255,6 @@ export function WeKnoraKnowledgeDetailPage() {
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{canManageWiki && (
<Button
size="sm"
variant="outline"
@@ -297,22 +263,6 @@ export function WeKnoraKnowledgeDetailPage() {
<FileText className="mr-2 size-4" />
Wiki管理
</Button>
)}
{canUpload && (
<Button
size="sm"
className="shrink-0"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Upload className="mr-2 size-4" />
)}
上传文件
</Button>
)}
</div>
</div>
)}
@@ -323,7 +273,7 @@ export function WeKnoraKnowledgeDetailPage() {
title={`${details.data.name || "知识库"} 详情`}
src={frameSrc}
className="h-full min-h-0 w-full flex-1 border-0 bg-background"
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-popups"
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-popups allow-popups-to-escape-sandbox"
referrerPolicy="no-referrer"
onLoad={() => postThemeToFrame(frameRef.current)}
/>
@@ -1,5 +1,7 @@
import {
BookOpen,
BookOpenText,
ChevronRight,
Database,
FileText,
Globe2,
@@ -29,8 +31,11 @@ import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import {
type LlmWikiKnowledgeBase,
type LlmWikiSearchResponse,
type LlmWikiSearchResult,
type LlmWikiScope,
searchLlmWiki,
useLlmWikiActions,
@@ -53,6 +58,49 @@ function messageOf(error: unknown): string {
return error instanceof Error ? error.message : "操作失败,请稍后重试";
}
function searchModeLabel(mode: LlmWikiSearchResponse["mode"]) {
if (mode === "vector") return "本地 Wiki 向量检索";
if (mode === "mixed") return "Wiki 混合检索";
return "Wiki 接口检索";
}
function wikiPageTypeLabel(value?: string) {
const normalized = (value || "").trim().toLowerCase();
if (normalized === "directory") return "目录页";
if (normalized === "overview") return "概览页";
if (normalized === "article") return "文章页";
return value?.trim() || "Wiki 页面";
}
function formatSearchTime(value?: string | null) {
if (!value) return "";
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: date.toLocaleString("zh-CN", { hour12: false });
}
function resultPreview(result: LlmWikiSearchResult) {
const text = (
result.matched_sections?.map((section) => section.content).filter(Boolean).join("\n\n") ||
result.summary ||
result.content ||
""
);
return text
.replace(/^---[\s\S]*?---\s*/m, "")
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
.replace(/\[\[([^\]]+)\]\]/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/[>*_`~|]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
export function WeKnoraKnowledgeWorkspace() {
const navigate = useNavigate();
const isAdmin = getStoredAuth()?.system_role === "admin";
@@ -63,15 +111,13 @@ export function WeKnoraKnowledgeWorkspace() {
const [createOpen, setCreateOpen] = useState(false);
const [searchText, setSearchText] = useState("");
const [searching, setSearching] = useState(false);
const [searchResults, setSearchResults] = useState<Array<{
chunk_id: string;
title: string;
content: string;
score: number;
knowledge_base_name: string;
}>>([]);
const [searchResponse, setSearchResponse] = useState<LlmWikiSearchResponse | null>(null);
const [selectedSearchResult, setSelectedSearchResult] = useState<LlmWikiSearchResult | null>(null);
useEffect(() => setSearchResults([]), [tab]);
useEffect(() => {
setSearchResponse(null);
setSelectedSearchResult(null);
}, [tab]);
const busy = actions.create.isPending || actions.remove.isPending || actions.publication.isPending;
const openDetails = (item: LlmWikiKnowledgeBase) => {
@@ -87,7 +133,7 @@ export function WeKnoraKnowledgeWorkspace() {
if (!query) return;
setSearching(true);
try {
setSearchResults(await searchLlmWiki({ query, scope }));
setSearchResponse(await searchLlmWiki({ query, scope }));
} catch (error) {
toast.error(messageOf(error));
} finally {
@@ -154,9 +200,9 @@ export function WeKnoraKnowledgeWorkspace() {
<div className="flex w-full max-w-xl items-center gap-2 rounded-xl border bg-background/80 p-1 shadow-sm sm:w-auto">
<div className="relative min-w-0 flex-1 sm:w-80">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input value={searchText} onChange={(event) => setSearchText(event.target.value)} onKeyDown={(event) => event.key === "Enter" && void runSearch()} placeholder={tab === "public" ? "检索公共知识" : "检索我的知识"} className="border-0 bg-transparent pl-9 shadow-none focus-visible:ring-0" />
<Input value={searchText} onChange={(event) => setSearchText(event.target.value)} onKeyDown={(event) => event.key === "Enter" && void runSearch()} placeholder={tab === "public" ? "检索公共 Wiki 文档" : "检索我的 Wiki 文档"} className="border-0 bg-transparent pl-9 shadow-none focus-visible:ring-0" />
</div>
<Button variant="secondary" className="rounded-lg px-4" onClick={() => void runSearch()} disabled={searching || !searchText.trim()}>{searching ? <Loader2 className="size-4 animate-spin" /> : "检索"}</Button>
<Button variant="secondary" className="rounded-lg px-4" onClick={() => void runSearch()} disabled={searching || !searchText.trim()}>{searching ? <Loader2 className="size-4 animate-spin" /> : "检索 Wiki"}</Button>
</div>
)}
</div>
@@ -166,15 +212,95 @@ export function WeKnoraKnowledgeWorkspace() {
{isAdmin && <TabsContent value="settings" className="mt-5"><ConnectionSettings /></TabsContent>}
</Tabs>
{searchResults.length > 0 && tab !== "settings" && (
<Card className="mt-5">
<CardHeader className="pb-3"><CardTitle className="text-base">检索结果</CardTitle><CardDescription>结果由知识服务检索返回,问答中使用同一授权链路。</CardDescription></CardHeader>
<CardContent className="space-y-3">{searchResults.map((result) => <div key={result.chunk_id} className="rounded-lg border p-3"><div className="flex items-center justify-between gap-3 text-sm font-medium"><span>{result.title || "未命名来源"}</span><Badge variant="outline">{result.knowledge_base_name}</Badge></div><p className="mt-2 line-clamp-3 whitespace-pre-wrap text-sm text-muted-foreground">{result.content}</p></div>)}</CardContent>
{searchResponse && tab !== "settings" && (
<Card className="mt-5 overflow-hidden border-emerald-200/70 bg-gradient-to-br from-emerald-50/70 via-card to-card shadow-sm dark:border-emerald-900/60 dark:from-emerald-950/20">
<CardHeader className="border-b border-emerald-200/60 pb-4 dark:border-emerald-900/50">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-emerald-600 text-white shadow-sm shadow-emerald-600/20">
<BookOpenText className="size-5" />
</div>
<div className="min-w-0">
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
Wiki 文档检索
<Badge className="border-emerald-200 bg-emerald-100 text-emerald-800 hover:bg-emerald-100 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">
{searchResponse.results.length} 个 Wiki 页面
</Badge>
</CardTitle>
<CardDescription className="mt-1.5 leading-5">
本次直接检索知识库生成的 Wiki 页面,不检索上传原文或原文分块。
</CardDescription>
</div>
</div>
<Badge variant="outline" className="shrink-0 bg-background/80 text-emerald-700 dark:text-emerald-300">
{searchModeLabel(searchResponse.mode)}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3 p-4">
{searchResponse.results.length === 0 ? (
<div className="flex min-h-36 flex-col items-center justify-center rounded-xl border border-dashed bg-background/70 px-6 text-center">
<BookOpenText className="mb-3 size-8 text-muted-foreground/60" />
<div className="text-sm font-medium">已完成 Wiki 文档检索,暂未命中相关页面</div>
<div className="mt-1 text-xs text-muted-foreground">可以缩短关键词或换一个主题后重试。</div>
</div>
) : searchResponse.results.map((result) => {
const heading = result.matched_heading || result.matched_sections?.find((section) => section.heading)?.heading;
const updatedAt = formatSearchTime(result.updated_at);
return (
<button
type="button"
key={`${result.knowledge_base_id}:${result.wiki_slug || result.id}`}
className="group w-full rounded-xl border bg-background/90 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-300 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/40"
onClick={() => setSelectedSearchResult(result)}
>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
<BookOpenText className="size-4.5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">Wiki 文档</Badge>
<Badge variant="outline">{wikiPageTypeLabel(result.page_type)}</Badge>
<span className="truncate text-xs text-muted-foreground">{result.knowledge_base_name}</span>
</div>
<div className="mt-2 line-clamp-2 text-[15px] font-semibold leading-6 text-foreground">{result.title || "未命名 Wiki 页面"}</div>
{heading && <div className="mt-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">命中章节:{heading}</div>}
<p className="mt-2 line-clamp-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{resultPreview(result)}</p>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span>相关度 {Math.round(Math.max(0, result.score || 0) * 100)}%</span>
{updatedAt && <span>更新于 {updatedAt}</span>}
</div>
<span className="inline-flex items-center font-medium text-emerald-700 transition group-hover:translate-x-0.5 dark:text-emerald-300">
抽屉查看完整 Wiki <ChevronRight className="ml-1 size-3.5" />
</span>
</div>
</div>
</div>
</button>
);
})}
</CardContent>
</Card>
)}
</div>
</div>
<WeKnoraSourceDrawer
open={selectedSearchResult !== null}
target={selectedSearchResult ? {
knowledgeBaseId: selectedSearchResult.knowledge_base_id,
wikiSlug: selectedSearchResult.wiki_slug,
tab: "wiki",
} : null}
title={selectedSearchResult?.title}
quote={selectedSearchResult ? resultPreview(selectedSearchResult) : ""}
onOpenChange={(open) => {
if (!open) setSelectedSearchResult(null);
}}
/>
<CreateKnowledgeDialog open={createOpen} pending={actions.create.isPending} onOpenChange={setCreateOpen} onCreate={async (input) => {
try {
const created = await actions.create.mutateAsync(input);
@@ -1,5 +1,14 @@
import { Download, FileText, Loader2, Maximize2, Minimize2 } from "lucide-react";
import {
BookOpenText,
Download,
FileDown,
FileText,
Loader2,
Maximize2,
Minimize2,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -17,9 +26,20 @@ import { SpreadsheetPreview } from "@/components/workspace/artifacts/spreadsheet
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { cn } from "@/lib/utils";
import { reasoningPlugins } from "@/core/streamdown";
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
import {
exportableWikiMarkdown,
renderableWikiMarkdown,
safeWikiFileName,
stripWikiFrontmatter,
wikiLinkMarkdown,
} from "@/core/llmwiki/wiki-page-markdown";
import { markdownToDocxBlob } from "@/core/scheduled-tasks/markdown-docx";
import {
getLlmWikiDocument,
getLlmWikiDocumentPreview,
getLlmWikiWikiIndexStatus,
getLlmWikiWikiPage,
getLocalLlmWikiWikiPage,
listLlmWikiDocumentChunks,
type LlmWikiChunk,
@@ -61,8 +81,34 @@ type CachedSourceDetail = {
const sourceDetailCache = new Map<string, CachedSourceDetail>();
async function getAvailableWikiPage(
knowledgeBaseId: string,
wikiSlug: string,
): Promise<LlmWikiWikiPage> {
const status = await getLlmWikiWikiIndexStatus(knowledgeBaseId).catch(() => null);
if (status?.local_index_enabled) {
return getLocalLlmWikiWikiPage(knowledgeBaseId, wikiSlug).catch(() =>
getLlmWikiWikiPage(knowledgeBaseId, wikiSlug),
);
}
if (status?.local_index_enabled === false) {
return getLlmWikiWikiPage(knowledgeBaseId, wikiSlug);
}
try {
return await getLlmWikiWikiPage(knowledgeBaseId, wikiSlug);
} catch (remoteError) {
try {
return await getLocalLlmWikiWikiPage(knowledgeBaseId, wikiSlug);
} catch {
// Do not mask the useful remote-page error with a local-index-disabled
// response when the status request itself was unavailable.
throw remoteError;
}
}
}
async function resolveTargetDetail(target: WeKnoraSourceTarget): Promise<DetailTarget> {
if (target.documentId || target.wikiSlug || !target.chunkId) {
if (target.wikiSlug || !target.chunkId) {
return target;
}
const params = new URLSearchParams({
@@ -74,12 +120,14 @@ async function resolveTargetDetail(target: WeKnoraSourceTarget): Promise<DetailT
);
const payload = (await response.json().catch(() => null)) as SourceContextResponse | null;
if (!response.ok || payload?.success !== true) {
throw new Error("无法定位原文文档详情");
if (target.documentId) return target;
throw new Error("无法定位当前引用详情");
}
const documentId = payload.data?.document_id?.trim();
const wikiSlug = payload.data?.wiki_page?.slug?.trim();
if (!documentId && !wikiSlug) {
throw new Error("当前引用缺少原文文档标识,无法打开详情");
if (target.documentId) return target;
throw new Error("当前引用缺少 Wiki 页面或文档标识,无法打开详情");
}
return {
...target,
@@ -96,8 +144,14 @@ function formatDate(value?: string | null) {
}
function displayType(document?: LlmWikiDocument | null, wiki?: LlmWikiWikiPage | null) {
if (wiki) {
const pageType = String(wiki.page_type || "").toLowerCase();
if (pageType === "directory") return "Wiki 目录页";
if (pageType === "overview") return "Wiki 概览页";
if (pageType === "article") return "Wiki 文章页";
return "Wiki 文档";
}
return (
wiki?.page_type ||
document?.file_type ||
document?.type ||
document?.source ||
@@ -109,8 +163,23 @@ function cleanText(value?: string | null) {
return (value || "").replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
}
function stripFrontmatter(value: string) {
return value.replace(/^\s*---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, "").trim();
function downloadMarkdown(markdown: string, fileName: string) {
downloadBlob(
new Blob([markdown], { type: "text/markdown;charset=utf-8" }),
`${safeWikiFileName(fileName)}.md`,
);
}
function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.rel = "noopener";
window.document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
function fileExtension(document?: LlmWikiDocument | null) {
@@ -183,7 +252,7 @@ function isWordDocument(document?: LlmWikiDocument | null) {
}
function markdownFromChunks(chunks: LlmWikiChunk[]) {
return stripFrontmatter(
return stripWikiFrontmatter(
chunks.map((chunk) => cleanText(chunk.content)).filter(Boolean).join("\n\n"),
);
}
@@ -468,6 +537,7 @@ export function WeKnoraSourceDrawer({
const [previewLoading, setPreviewLoading] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [downloading, setDownloading] = useState(false);
const [wikiDownload, setWikiDownload] = useState<"md" | "word" | null>(null);
const [resolvedDocumentId, setResolvedDocumentId] = useState("");
const targetKey = useMemo(() => {
@@ -518,7 +588,9 @@ export function WeKnoraSourceDrawer({
void (async () => {
const detailTarget = await resolveTargetDetail(target);
if (!cancelled) setResolvedDocumentId(detailTarget.documentId || "");
const isWikiTarget = Boolean(detailTarget.wikiSlug);
const previewDocumentId = isWikiTarget ? "" : detailTarget.documentId || "";
if (!cancelled) setResolvedDocumentId(previewDocumentId);
const tasks: Promise<void>[] = [];
let loadedDocument: LlmWikiDocument | null = null;
let loadedWikiPage: LlmWikiWikiPage | null = null;
@@ -528,29 +600,23 @@ export function WeKnoraSourceDrawer({
if (detailTarget.wikiSlug) {
tasks.push(
getLocalLlmWikiWikiPage(detailTarget.knowledgeBaseId, detailTarget.wikiSlug)
getAvailableWikiPage(detailTarget.knowledgeBaseId, detailTarget.wikiSlug)
.then((page) => {
loadedWikiPage = page;
if (!cancelled) setWikiPage(page);
})
.catch((error) => {
// New Wiki citations have no legacy document/chunk fallback, so
// surface mirror/index failures instead of rendering an empty drawer.
if (!detailTarget.documentId) throw error;
return undefined;
}),
);
}
if (detailTarget.documentId) {
if (previewDocumentId) {
loadedDocument = await getLlmWikiDocument(
detailTarget.knowledgeBaseId,
detailTarget.documentId,
previewDocumentId,
);
if (!cancelled) setDocument(loadedDocument);
loadedChunks = await listLlmWikiDocumentChunks(
detailTarget.knowledgeBaseId,
detailTarget.documentId,
previewDocumentId,
1,
200,
)
@@ -562,7 +628,7 @@ export function WeKnoraSourceDrawer({
}
const previewBlobPromise = isWordDocument(loadedDocument)
? Promise.resolve(new Blob())
: getLlmWikiDocumentPreview(detailTarget.knowledgeBaseId, detailTarget.documentId);
: getLlmWikiDocumentPreview(detailTarget.knowledgeBaseId, previewDocumentId);
const previewTask = previewBlobPromise
.then((blob) => {
return buildPreviewState(
@@ -590,11 +656,11 @@ export function WeKnoraSourceDrawer({
}
if (tasks.length === 0) {
throw new Error("当前引用缺少原文文档标识,无法打开详情");
throw new Error("当前引用缺少 Wiki 页面或文档标识,无法打开详情");
}
await Promise.all(tasks);
sourceDetailCache.set(targetKey, {
documentId: detailTarget.documentId || "",
documentId: previewDocumentId,
document: loadedDocument,
wikiPage: loadedWikiPage,
preview: loadedPreview,
@@ -622,7 +688,20 @@ export function WeKnoraSourceDrawer({
title ||
"文档详情";
const summary = cleanText(wikiPage?.summary || document?.description);
const wikiContent = stripFrontmatter(cleanText(wikiPage?.content));
const wikiContent = stripWikiFrontmatter(cleanText(wikiPage?.content));
const wikiDisplayContent = wikiPage && target
? renderableWikiMarkdown(resolvedTitle, wikiContent, target.knowledgeBaseId)
: "";
const wikiMarkdown = wikiPage
? wikiLinkMarkdown(
exportableWikiMarkdown(resolvedTitle, summary, wikiContent),
target?.knowledgeBaseId || "",
)
: "";
const resolvedWikiSlug = wikiPage?.slug || target?.wikiSlug || "";
const wikiWorkspacePath = target && resolvedWikiSlug
? buildWeKnoraWikiPagePath(target.knowledgeBaseId, resolvedWikiSlug)
: "";
const downloadOriginal = async () => {
if (!target || !resolvedDocumentId) return;
@@ -644,6 +723,28 @@ export function WeKnoraSourceDrawer({
}
};
const downloadWiki = async (format: "md" | "word") => {
if (!wikiMarkdown) {
toast.error("当前 Wiki 页面没有可下载内容");
return;
}
setWikiDownload(format);
try {
if (format === "md") {
downloadMarkdown(wikiMarkdown, resolvedTitle);
toast.success("Markdown 文件已生成");
} else {
const blob = await markdownToDocxBlob(wikiMarkdown, resolvedTitle);
downloadBlob(blob, `${safeWikiFileName(resolvedTitle)}.docx`);
toast.success("Word 文件已生成");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载 Wiki 页面失败");
} finally {
setWikiDownload(null);
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
@@ -656,46 +757,72 @@ export function WeKnoraSourceDrawer({
<SheetTitle className="sr-only">{resolvedTitle}</SheetTitle>
<SheetDescription className="sr-only">知识库 Wiki 页面详情</SheetDescription>
<div className="relative flex h-full min-h-0 flex-col bg-background">
<div className="shrink-0 border-b px-5 py-4 pr-64">
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-700">
<FileText className="size-5" />
</div>
<div className="min-w-0">
<div className="truncate text-base font-semibold text-foreground">{resolvedTitle}</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline">{displayType(document, wikiPage)}</Badge>
{formatDate(document?.created_at || wikiPage?.created_at) && (
<span>{formatDate(document?.created_at || wikiPage?.created_at)}</span>
)}
{document?.parse_status && <span>{document.parse_status}</span>}
<div className="shrink-0 border-b px-5 py-4 pr-12">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-64 flex-1 items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-700">
{wikiPage ? <BookOpenText className="size-5" /> : <FileText className="size-5" />}
</div>
<div className="min-w-0">
<div className="truncate text-base font-semibold text-foreground">{resolvedTitle}</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline">{displayType(document, wikiPage)}</Badge>
{formatDate(document?.created_at || wikiPage?.created_at) && (
<span>{formatDate(document?.created_at || wikiPage?.created_at)}</span>
)}
{document?.parse_status && <span>{document.parse_status}</span>}
</div>
</div>
</div>
</div>
<div className="absolute top-4 right-12 flex items-center gap-2">
{resolvedDocumentId && (
<Button type="button" size="sm" variant="outline" onClick={() => void downloadOriginal()} disabled={downloading}>
{downloading ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
下载原文
<div className="flex flex-wrap items-center justify-end gap-2">
{wikiPage && (
<>
<Button type="button" size="sm" variant="outline" onClick={() => void downloadWiki("md")} disabled={wikiDownload !== null}>
{wikiDownload === "md" ? <Loader2 className="size-4 animate-spin" /> : <FileDown className="size-4" />}
下载 MD
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => void downloadWiki("word")} disabled={wikiDownload !== null}>
{wikiDownload === "word" ? <Loader2 className="size-4 animate-spin" /> : <FileText className="size-4" />}
下载 Word
</Button>
{wikiWorkspacePath && (
<Button type="button" size="sm" variant="outline" asChild>
<Link
to={wikiWorkspacePath}
state={{ wikiPage }}
onClick={() => onOpenChange(false)}
>
<BookOpenText className="size-4" />
打开所在 Wiki
</Link>
</Button>
)}
</>
)}
{resolvedDocumentId && (
<Button type="button" size="sm" variant="outline" onClick={() => void downloadOriginal()} disabled={downloading}>
{downloading ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
下载原文
</Button>
)}
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setFullscreen((value) => !value)}
aria-label={fullscreen ? "退出放大查看" : "单独放大查看文档"}
>
{fullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
{fullscreen ? "退出放大" : "放大查看"}
</Button>
)}
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setFullscreen((value) => !value)}
aria-label={fullscreen ? "退出放大查看" : "单独放大查看文档"}
>
{fullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
{fullscreen ? "退出放大" : "放大查看"}
</Button>
</div>
</div>
</div>
{loading && (
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-5 animate-spin" />
正在加载 Wiki 页面
正在加载完整 Wiki 文档
</div>
)}
@@ -785,13 +912,16 @@ export function WeKnoraSourceDrawer({
</section>
) : null}
{wikiContent && (
{wikiDisplayContent && (
<section className="border-b px-5 py-4">
<div className="mb-2 text-sm font-medium text-foreground">Wiki 原文</div>
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-foreground">
<BookOpenText className="size-4 text-emerald-600" />
Wiki 文档正文
</div>
<div className="rounded-lg border bg-background p-5">
<MarkdownContent
className="max-w-none"
content={wikiContent}
content={wikiDisplayContent}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
@@ -0,0 +1,359 @@
import {
ArrowLeft,
BookOpenText,
ChevronDown,
ChevronRight,
Download,
FileText,
Folder,
Loader2,
RefreshCw,
Search,
} from "lucide-react";
import { type ReactNode, useEffect, useMemo, useState } from "react";
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
import {
buildWikiTree,
filterWikiPagesWithAncestors,
type WikiTreeNode,
} from "@/core/llmwiki/wiki-tree";
import { reasoningPlugins } from "@/core/streamdown";
import { markdownToDocxBlob } from "@/core/scheduled-tasks/markdown-docx";
import {
exportableWikiMarkdown,
renderableWikiMarkdown,
safeWikiFileName,
stripWikiFrontmatter,
wikiLinkMarkdown,
} from "@/core/llmwiki/wiki-page-markdown";
import {
type LlmWikiWikiPage,
useLlmWikiKnowledgeBase,
useLlmWikiWikiPage,
useLlmWikiWikiPages,
} from "@/strategy-components/api/llmwiki";
const LIST_PATH = "/page/workspace/knowledge";
function cleanText(value?: string | null) {
return (value || "").replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
}
function formatDate(value?: string | null) {
if (!value) return "";
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: date.toLocaleString("zh-CN", { hour12: false });
}
function pageTypeLabel(value?: string) {
const pageType = String(value || "").toLowerCase();
if (pageType === "directory") return "Wiki 目录页";
if (pageType === "overview") return "Wiki 概览页";
if (pageType === "article") return "Wiki 文章页";
return "Wiki 文档";
}
function directoryLabel(value: string) {
const labels: Record<string, string> = {
concept: "概念",
entity: "实体",
summary: "摘要",
index: "索引",
"未分类": "其他页面",
};
return labels[value.toLowerCase()] || value;
}
function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.rel = "noopener";
window.document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
export function WeKnoraWikiPageView() {
const { noteId = "" } = useParams();
const [searchParams] = useSearchParams();
const location = useLocation();
const navigate = useNavigate();
const wikiSlug = searchParams.get("wikiSlug")?.trim() || "";
const page = useLlmWikiWikiPage(noteId || null, wikiSlug || null, !!noteId && !!wikiSlug);
const knowledgeBase = useLlmWikiKnowledgeBase(noteId || null, !!noteId);
const pages = useLlmWikiWikiPages(noteId || null, 1, 500, "", !!noteId);
const [downloading, setDownloading] = useState<"md" | "word" | null>(null);
const [directoryQuery, setDirectoryQuery] = useState("");
const [collapsedDirectories, setCollapsedDirectories] = useState<Set<string>>(() => new Set());
const routedPage = (location.state as { wikiPage?: LlmWikiWikiPage } | null)?.wikiPage;
const pageData = page.data || (routedPage?.slug === wikiSlug ? routedPage : undefined);
const pagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
const filteredPages = useMemo(
() => filterWikiPagesWithAncestors(pagesList, directoryQuery),
[directoryQuery, pagesList],
);
const wikiTree = useMemo(() => buildWikiTree(filteredPages), [filteredPages]);
useEffect(() => {
const activeParents = wikiTree.parentKeysBySlug.get(wikiSlug) || [];
if (activeParents.length === 0) return;
setCollapsedDirectories((previous) => {
if (activeParents.every((key) => !previous.has(key))) return previous;
const next = new Set(previous);
activeParents.forEach((key) => next.delete(key));
return next;
});
}, [wikiSlug, wikiTree.parentKeysBySlug]);
const title = pageData?.title || wikiSlug || "Wiki 页面";
const summary = cleanText(pageData?.summary);
const content = stripWikiFrontmatter(cleanText(pageData?.content));
const displayMarkdown = pageData
? renderableWikiMarkdown(title, content, noteId)
: "";
const exportMarkdown = pageData
? wikiLinkMarkdown(exportableWikiMarkdown(title, summary, content), noteId)
: "";
const download = async (format: "md" | "word") => {
if (!exportMarkdown) return;
setDownloading(format);
try {
const fileName = safeWikiFileName(title);
if (format === "md") {
downloadBlob(
new Blob([exportMarkdown], { type: "text/markdown;charset=utf-8" }),
`${fileName}.md`,
);
} else {
downloadBlob(
await markdownToDocxBlob(exportMarkdown, title),
`${fileName}.docx`,
);
}
toast.success(format === "md" ? "Markdown 文件已生成" : "Word 文件已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载 Wiki 页面失败");
} finally {
setDownloading(null);
}
};
const toggleDirectory = (key: string) => {
setCollapsedDirectories((previous) => {
const next = new Set(previous);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const renderDirectoryNode = (node: WikiTreeNode, depth = 0): ReactNode => {
const hasChildren = node.children.length > 0;
const expanded = directoryQuery.trim() ? true : !collapsedDirectories.has(node.key);
const active = node.page?.slug === wikiSlug;
const label = node.page ? node.label : directoryLabel(node.label);
return (
<div key={node.key}>
<div
className={`group flex min-h-9 items-center gap-1 rounded-lg pr-2 text-sm transition-colors ${
active
? "bg-emerald-100 font-medium text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200"
: "text-muted-foreground hover:bg-muted/70 hover:text-foreground"
}`}
style={{ paddingLeft: `${8 + depth * 14}px` }}
>
{hasChildren ? (
<button
type="button"
className="flex size-6 shrink-0 items-center justify-center rounded hover:bg-background/70"
onClick={() => toggleDirectory(node.key)}
aria-label={expanded ? "收起目录" : "展开目录"}
>
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
</button>
) : (
<span className="size-6 shrink-0" />
)}
{node.page ? (
<Link
to={buildWeKnoraWikiPagePath(noteId, node.page.slug)}
state={{ wikiPage: node.page }}
className="flex min-w-0 flex-1 items-center gap-2 py-2"
aria-current={active ? "page" : undefined}
title={node.page.title || node.page.slug}
>
<FileText className={`size-3.5 shrink-0 ${active ? "text-emerald-600" : "text-muted-foreground/70"}`} />
<span className="truncate">{label}</span>
</Link>
) : (
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 py-2 text-left"
onClick={() => toggleDirectory(node.key)}
title={label}
>
<Folder className="size-3.5 shrink-0 text-amber-500" />
<span className="truncate">{label}</span>
<span className="ml-auto text-[10px] tabular-nums text-muted-foreground/70">{node.count}</span>
</button>
)}
</div>
{hasChildren && expanded && (
<div>{node.children.map((child) => renderDirectoryNode(child, depth + 1))}</div>
)}
</div>
);
};
if (!wikiSlug) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground">
当前链接缺少 Wiki 页面标识
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
</div>
);
}
if (page.isLoading && !pageData) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-5 animate-spin" />
正在加载 Wiki 页面
</div>
);
}
if (!pageData) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<BookOpenText className="size-6" />
</div>
<div>
<div className="font-medium text-foreground">无法打开 Wiki 页面</div>
<div className="mt-1 text-sm text-muted-foreground">
{page.error instanceof Error ? page.error.message : "页面不存在或当前账号无权访问"}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
<Button onClick={() => void page.refetch()}>
<RefreshCw className="mr-2 size-4" />重试
</Button>
</div>
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col bg-slate-50/70 dark:bg-slate-950/40">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b bg-background px-4 py-3 shadow-sm sm:px-6">
<div className="flex min-w-0 flex-1 items-center gap-3">
<Button size="icon-sm" variant="ghost" onClick={() => navigate(LIST_PATH)} aria-label="返回知识库">
<ArrowLeft className="size-4" />
</Button>
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
<BookOpenText className="size-5" />
</div>
<div className="min-w-0">
<h1 className="truncate text-base font-semibold text-foreground">{title}</h1>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline">{pageTypeLabel(pageData.page_type)}</Badge>
<span>{knowledgeBase.data?.name || "知识库 Wiki"}</span>
{formatDate(pageData.updated_at || pageData.created_at) && (
<span>更新于 {formatDate(pageData.updated_at || pageData.created_at)}</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => void download("md")} disabled={downloading !== null}>
{downloading === "md" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
下载 MD
</Button>
<Button variant="outline" size="sm" onClick={() => void download("word")} disabled={downloading !== null}>
{downloading === "word" ? <Loader2 className="size-4 animate-spin" /> : <FileText className="size-4" />}
下载 Word
</Button>
</div>
</header>
<div className="flex min-h-0 flex-1 overflow-hidden">
<aside className="flex w-64 shrink-0 flex-col border-r bg-background/90 lg:w-72">
<div className="shrink-0 border-b px-3 py-3">
<div className="flex items-center justify-between gap-3 px-1">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<BookOpenText className="size-4 text-emerald-600" />
Wiki 目录
</div>
<span className="text-xs tabular-nums text-muted-foreground">
{pagesList.length} 篇
</span>
</div>
<div className="relative mt-3">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={directoryQuery}
onChange={(event) => setDirectoryQuery(event.target.value)}
placeholder="搜索目录或页面"
className="h-9 bg-muted/35 pl-9"
aria-label="搜索 Wiki 目录"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto p-2">
{pages.isLoading && pagesList.length === 0 ? (
<div className="flex items-center justify-center gap-2 px-3 py-10 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载目录
</div>
) : wikiTree.roots.length === 0 ? (
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
{directoryQuery.trim() ? "没有匹配的 Wiki 页面" : "暂无 Wiki 目录"}
</div>
) : (
<nav aria-label="Wiki 目录" className="space-y-0.5">
{wikiTree.roots.map((node) => renderDirectoryNode(node))}
</nav>
)}
</div>
</aside>
<main className="min-h-0 flex-1 overflow-auto px-4 py-6 sm:px-8">
<article className="mx-auto max-w-5xl overflow-hidden rounded-2xl border bg-background shadow-sm">
{summary && (
<section className="border-b bg-muted/30 px-6 py-5 sm:px-10">
<div className="mb-2 text-xs font-medium tracking-wide text-muted-foreground">页面摘要</div>
<p className="text-sm leading-7 text-foreground">{summary}</p>
</section>
)}
<section className="px-6 py-7 sm:px-10 sm:py-9">
<MarkdownContent
className="max-w-none"
content={displayMarkdown}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
</section>
</article>
</main>
</div>
</div>
);
}
export default WeKnoraWikiPageView;