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
+1700 -301

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>
);
}
@@ -55,7 +55,13 @@ interface ArtifactsProviderProps {
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => {
setArtifactsState(Array.isArray(next) ? next : []);
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);
@@ -107,14 +113,8 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
suppressedAutoOpenKeyRef.current = null;
}, []);
const value: ArtifactsContextType = {
artifacts,
setArtifacts,
open,
autoOpen,
autoSelect,
setOpen: (isOpen: boolean) => {
const setOpen = useCallback(
(isOpen: boolean) => {
if (!isOpen && autoOpen) {
setAutoOpen(false);
setAutoSelect(false);
@@ -124,6 +124,17 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
}
setOpenState(isOpen);
},
[autoOpen, selectedArtifactKey],
);
const value: ArtifactsContextType = {
artifacts,
setArtifacts,
open,
autoOpen,
autoSelect,
setOpen,
selectedArtifact,
selectedArtifactKey,
@@ -1,4 +1,5 @@
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>
@@ -6,6 +6,7 @@ import {
BookMarkedIcon,
CheckIcon,
FileTextIcon,
FolderTreeIcon,
GraduationCapIcon,
LightbulbIcon,
PaperclipIcon,
@@ -375,6 +376,14 @@ export function InputBox({
});
}, [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(() => {
onContextChange?.({
@@ -762,6 +771,35 @@ export function InputBox({
<span className="text-xs font-normal">写作</span>
</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
@@ -271,14 +271,20 @@ function ToolCall({
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 };
}
+6
View File
@@ -7,6 +7,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
display: {
streamSpeedMeterEnabled: true,
ragModeEnabled: false,
artifactDirectoryModeEnabled: false,
contextCompressionEnabled: false,
citationCleanupWords: [],
fontScale: 1,
@@ -44,6 +45,11 @@ export interface LocalSettings {
* messages render tool results with the legacy generic search UI only.
*/
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;
@@ -1359,6 +1359,29 @@ page is successfully rebuilt. `WikiRetrievalService` partitions mixed requests
per mapping, so ready libraries stay local while incomplete libraries use that
Wiki-only fallback.
The frontend must keep Wiki citations visibly distinct from raw-document
citations while hiding provider-brand wording from user-facing labels, status
text, and errors. Any click on a Wiki source card opens the shared right-side
source drawer, which renders the complete Markdown page. When the local index
is disabled, the drawer reads the remote WeKnora Wiki-page endpoint directly
and must not surface the local-index-disabled response. A Wiki target must not
also load or render the legacy original-document preview, even when its citation
still carries a document id. The drawer supports
single-page Markdown and Word downloads, and its **Open in Wiki** link carries
both the DeerFlow knowledge-base mapping id and the exact `wikiSlug`. Wiki
search-result cards use this same drawer instead of navigating immediately.
Exact-page navigation uses the native
`knowledge/{mapping_id}/wiki/view?wikiSlug=...` reader, not the WeKnora detail
iframe. Keep legacy `knowledge/{mapping_id}?tab=wiki&wikiSlug=...` links fast by
redirecting them in `LlmWikiKnowledgePage` before its runtime-loading gate.
Drawer-to-reader navigation stays in the current SPA and passes the fetched
Wiki page in router state so the article can render before the background query
refresh completes. The native reader must retain a searchable/collapsible left
Wiki directory, reconstruct hierarchy from `parent_slug`, `wiki_path`,
`category_path`, and slug fallback, expand/highlight the active page, and keep
directory navigation on the same lightweight route rather than mounting the
WeKnora iframe.
Owners manually force a full sync/vectorization through `POST
/api/llmwiki/knowledge-bases/{id}/vectorize`; status is available through `GET
/api/llmwiki/knowledge-bases/{id}/wiki-index-status`. The Wiki management page
@@ -768,6 +768,27 @@ after the full-library readiness gate succeeds. Knowledge-base owners can click
`GET /api/llmwiki/knowledge-bases/{id}/wiki-index-status`. Existing chunk
citations remain readable while all new retrieval citations open Wiki pages.
The frontend labels these results simply as **Wiki** references; provider-brand
wording is intentionally hidden from user-facing labels and status text. Clicking any
part of a Wiki reference card opens one right-side drawer (rather than a second
detail dialog), renders the complete page as Markdown, and reads the remote
WeKnora Wiki page directly when the local index is disabled. Even if a Wiki
citation carries a legacy document id, the drawer does not mix in the uploaded
original-file preview. A single page can
be downloaded as Markdown or Word from the drawer. **Open in Wiki** navigates
to the exact DeerFlow knowledge-base mapping and Wiki slug. Wiki search-result
cards use the same drawer interaction. Exact Wiki links now open a lightweight
native reader at `knowledge/{mapping_id}/wiki/view?wikiSlug=...`; it fetches
only the selected page and never creates a WeKnora iframe session. The former
`knowledge/{mapping_id}?tab=wiki&wikiSlug=...` links redirect to the reader
before waiting for the LLMWiki runtime check. Drawer navigation stays in the
same SPA and passes the already-loaded page as route state for immediate paint.
The native reader also keeps a searchable, collapsible Wiki directory on the
left. It reconstructs hierarchy from `parent_slug`, `wiki_path`,
`category_path`, and the slug fallback, highlights and expands the current
page, and switches directory entries through the same lightweight reader route
without mounting the WeKnora iframe.
Wiki management also provides **导出 Excel**. `GET
/api/llmwiki/knowledge-bases/{id}/wiki/export.xlsx` exports every processed Wiki
page, including its full directory path, current directory, parent-directory