diff --git a/frontend-web/src/components/workspace/artifacts/artifact-directory.tsx b/frontend-web/src/components/workspace/artifacts/artifact-directory.tsx
new file mode 100644
index 00000000..8956d653
--- /dev/null
+++ b/frontend-web/src/components/workspace/artifacts/artifact-directory.tsx
@@ -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;
+ onToggleDirectory: (path: string) => void;
+ selectedArtifact: string | null;
+ onSelect: (artifact: string) => void;
+}) {
+ return (
+ <>
+ {node.directories.map((directory) => {
+ const collapsed = collapsedDirectories.has(directory.path);
+ return (
+
+ onToggleDirectory(directory.path)}
+ title={directory.path}
+ >
+ {collapsed ? (
+
+ ) : (
+
+ )}
+ {collapsed ? (
+
+ ) : (
+
+ )}
+ {directory.name}
+
+ {!collapsed && (
+
+ )}
+
+ );
+ })}
+ {node.files.map((file) => {
+ const selected =
+ !!selectedArtifact &&
+ artifactPathname(selectedArtifact) === artifactPathname(file.artifact);
+ return (
+ onSelect(file.artifact)}
+ title={file.relativePath}
+ aria-current={selected ? "page" : undefined}
+ >
+ {getFileIcon(file.artifact, "size-4")}
+ {file.name}
+
+ );
+ })}
+ >
+ );
+}
+
+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>(
+ () => 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 (
+
+ );
+}
diff --git a/frontend-web/src/components/workspace/artifacts/context.tsx b/frontend-web/src/components/workspace/artifacts/context.tsx
index e1819e2d..bf3f059d 100644
--- a/frontend-web/src/components/workspace/artifacts/context.tsx
+++ b/frontend-web/src/components/workspace/artifacts/context.tsx
@@ -52,11 +52,17 @@ interface ArtifactsProviderProps {
children: ReactNode;
}
-export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
- const [artifacts, setArtifactsState] = useState([]);
- const setArtifacts = useCallback((next: string[]) => {
- setArtifactsState(Array.isArray(next) ? next : []);
- }, []);
+export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
+ const [artifacts, setArtifactsState] = useState([]);
+ 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(null);
const [selectedArtifactKey, setSelectedArtifactKey] = useState(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 (
diff --git a/frontend-web/src/components/workspace/artifacts/index.ts b/frontend-web/src/components/workspace/artifacts/index.ts
index 06c6bc71..270d9e68 100644
--- a/frontend-web/src/components/workspace/artifacts/index.ts
+++ b/frontend-web/src/components/workspace/artifacts/index.ts
@@ -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";
diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx
index 13796112..d84f534b 100644
--- a/frontend-web/src/components/workspace/chats/chat-box.tsx
+++ b/frontend-web/src/components/workspace/chats/chat-box.tsx
@@ -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[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(null);
const [backendReferenceBatches, setBackendReferenceBatches] = useState([]);
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 ? (
+
+
{
+ selectArtifact(artifact);
+ setArtifactsOpen(true);
+ }}
+ onRefresh={
+ canScanArtifactDirectory
+ ? () => void artifactListingQuery.refetch()
+ : undefined
+ }
+ refreshing={artifactListingQuery.isFetching}
+ />
+
+ {selectedArtifact ? (
+
+ ) : (
+ <>
+
+ setArtifactsOpen(false)}
+ >
+
+
+
+
}
+ title="请选择文件"
+ description="从左侧目录中选择一个文件进行查看"
+ />
+ >
+ )}
+
+
+ ) : selectedArtifact ? (
+
+ ) : (
+
+
+ setArtifactsOpen(false)}
+ >
+
+
+
+ {artifacts.length === 0 ? (
+
}
+ title="No artifact selected"
+ description="Select an artifact to view its details"
+ />
+ ) : (
+
+ )}
+
+ );
if (disableArtifacts) {
return (
@@ -455,13 +637,7 @@ const ChatBox: React.FC<{
-
+ {artifactDocumentPanel}
- ) : selectedArtifact ? (
-
+ ) : artifactPanelOpen ? (
+ artifactDocumentPanel
) : hasCitationReferences ? (
@@ -497,41 +667,7 @@ const ChatBox: React.FC<{
sources={referenceSources}
/>
- ) : (
-
-
- {
- setArtifactsOpen(false);
- }}
- >
-
-
-
- {thread.values.artifacts?.length === 0 ? (
-
}
- title="No artifact selected"
- description="Select an artifact to view its details"
- />
- ) : (
-
- )}
-
- )}
+ ) : null}
diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx
index cd61334e..a016d7e1 100644
--- a/frontend-web/src/components/workspace/input-box.tsx
+++ b/frontend-web/src/components/workspace/input-box.tsx
@@ -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 &&
写作
-
- }
- {!frontendRagActive &&
+ }
+ {!frontendRagActive &&
+
+ {artifactDirectoryModeEnabled && (
+
+ )}
+
+ 文件
+
+ }
+ {!frontendRagActive && ({
...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 (
@@ -303,7 +309,7 @@ function ToolCall({
mode: "citation" as const,
}));
return (
-
+
);
diff --git a/frontend-web/src/components/workspace/skill-display-sources.tsx b/frontend-web/src/components/workspace/skill-display-sources.tsx
index 6828ee22..7b83d174 100644
--- a/frontend-web/src/components/workspace/skill-display-sources.tsx
+++ b/frontend-web/src/components/workspace/skill-display-sources.tsx
@@ -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({
)}
- {source.url && (
+ {(source.url || isWikiSource) && (
previewTarget ? (
-
-
- {previewTarget.wikiSlug ? "Wiki 页面" : "知识库文档"}
+
+ {isWikiSource ? : }
+ {isWikiSource ? "Wiki 文档" : "知识库文档"}
) : (
@@ -143,19 +168,9 @@ export function SourceCard({
)}
{previewTarget && (
-
-
{
- event.stopPropagation();
- setDetailOpen(true);
- }}
- >
-
- {previewTarget.wikiSlug ? "查看 Wiki" : "查看详情"}
-
+
+ {isWikiSource ? "点击卡片查看完整 Wiki" : "点击卡片查看详情"}
+
)}
@@ -168,7 +183,7 @@ export function SourceCard({
onOpenChange={setDetailOpen}
/>
)}
-
+ {!previewTarget &&
@@ -205,13 +220,23 @@ export function SourceCard({
)}
- {paragraphs.length > 0 ? (
-
- {paragraphs.map((paragraph, index) => (
-
- {paragraph}
-
- ))}
+ {content ? (
+
+ {isWikiSource && (
+
+
+ 本次检索命中的 Wiki 文档内容
+
+ )}
+
+
+
) : null}
{source.url && !previewTarget && (
@@ -220,7 +245,7 @@ export function SourceCard({
)}
-
+ }
>
);
}
@@ -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, " "),
+ );
}
diff --git a/frontend-web/src/core/artifacts/listing.ts b/frontend-web/src/core/artifacts/listing.ts
new file mode 100644
index 00000000..978d0a5f
--- /dev/null
+++ b/frontend-web/src/core/artifacts/listing.ts
@@ -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 {
+ 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();
+
+ 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;
+}
diff --git a/frontend-web/src/core/i18n/locales/en-US.ts b/frontend-web/src/core/i18n/locales/en-US.ts
index de6a7d60..e8a7ad64 100644
--- a/frontend-web/src/core/i18n/locales/en-US.ts
+++ b/frontend-web/src/core/i18n/locales/en-US.ts
@@ -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",
diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts
index ab06ad4a..8610ac88 100644
--- a/frontend-web/src/core/i18n/locales/zh-CN.ts
+++ b/frontend-web/src/core/i18n/locales/zh-CN.ts
@@ -437,7 +437,7 @@ export const zhCN: Translations = {
lightDescription: "更明亮的配色,适合日间使用。",
darkDescription: "更暗的配色,减少眩光方便专注。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
- lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
+ lightgreenDescription: "采用清新绿色的浅色配色。",
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
languageTitle: "语言",
diff --git a/frontend-web/src/core/llmwiki/source-links.ts b/frontend-web/src/core/llmwiki/source-links.ts
index 43d776d1..b3088013 100644
--- a/frontend-web/src/core/llmwiki/source-links.ts
+++ b/frontend-web/src/core/llmwiki/source-links.ts
@@ -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");
diff --git a/frontend-web/src/core/llmwiki/wiki-page-markdown.ts b/frontend-web/src/core/llmwiki/wiki-page-markdown.ts
new file mode 100644
index 00000000..4b8e437b
--- /dev/null
+++ b/frontend-web/src/core/llmwiki/wiki-page-markdown.ts
@@ -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,
+ );
+}
diff --git a/frontend-web/src/core/llmwiki/wiki-tree.ts b/frontend-web/src/core/llmwiki/wiki-tree.ts
new file mode 100644
index 00000000..1db7ebc6
--- /dev/null
+++ b/frontend-web/src/core/llmwiki/wiki-tree.ts
@@ -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;
+};
+
+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();
+ for (const page of matched) {
+ let current: LlmWikiWikiPage | undefined = page;
+ const visited = new Set();
+ 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();
+ const directoryNodes = new Map();
+ const parentKeysBySlug = new Map();
+
+ 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([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 };
+}
diff --git a/frontend-web/src/core/settings/local.ts b/frontend-web/src/core/settings/local.ts
index b9c86665..c79ec805 100644
--- a/frontend-web/src/core/settings/local.ts
+++ b/frontend-web/src/core/settings/local.ts
@@ -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
diff --git a/frontend-web/src/pages/LlmWikiKnowledgePage.tsx b/frontend-web/src/pages/LlmWikiKnowledgePage.tsx
index a54b097e..2e3d745d 100644
--- a/frontend-web/src/pages/LlmWikiKnowledgePage.tsx
+++ b/frontend-web/src/pages/LlmWikiKnowledgePage.tsx
@@ -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 ;
+ }
if (runtime.isLoading) {
return ;
diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx
index c7c095ea..db55fc3b 100644
--- a/frontend-web/src/pages/WorkspaceRoutes.tsx
+++ b/frontend-web/src/pages/WorkspaceRoutes.tsx
@@ -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({
}
/>
+
+
+
+ }
+ />
;
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(response: Response, context: string): Promise {
if (!response.ok) {
const payload = await response.json().catch(() => null as unknown);
@@ -230,7 +256,7 @@ async function readJson(response: Response, context: string): Promise {
: 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 {
- const payload = await readJson<{ results: LlmWikiSearchResult[] }>(
+}): Promise {
+ return readJson(
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 {
- 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 连接",
+ "测试知识服务连接",
);
}
diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx
index 9f2ae5b4..8def6a95 100644
--- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx
+++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx
@@ -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(null);
- const fileInputRef = useRef(null);
const [frameSrc, setFrameSrc] = useState("");
const [frameVersion, setFrameVersion] = useState(0);
const [frameError, setFrameError] = useState(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 ;
+ }
+
if (runtime.isLoading || details.isLoading) {
return (
@@ -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 (
- {(canUpload || canEditCurrentWikiArticle) && (
+ {canManageWiki && (
-
{
- 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);
- }
- })();
- }}
- />
{canEditCurrentWikiArticle && (
- {canManageWiki && (
Wiki管理
- )}
- {canUpload && (
- fileInputRef.current?.click()}
- disabled={uploading}
- >
- {uploading ? (
-
- ) : (
-
- )}
- 上传文件
-
- )}
)}
@@ -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)}
/>
diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace.tsx
index 04a20482..e320a272 100644
--- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace.tsx
+++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace.tsx
@@ -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
>([]);
+ const [searchResponse, setSearchResponse] = useState(null);
+ const [selectedSearchResult, setSelectedSearchResult] = useState(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() {
)}
@@ -166,15 +212,95 @@ export function WeKnoraKnowledgeWorkspace() {
{isAdmin &&
}
- {searchResults.length > 0 && tab !== "settings" && (
-
- 检索结果 结果由知识服务检索返回,问答中使用同一授权链路。
- {searchResults.map((result) => {result.title || "未命名来源"} {result.knowledge_base_name}
{result.content}
)}
+ {searchResponse && tab !== "settings" && (
+
+
+
+
+
+
+
+
+
+ Wiki 文档检索
+
+ {searchResponse.results.length} 个 Wiki 页面
+
+
+
+ 本次直接检索知识库生成的 Wiki 页面,不检索上传原文或原文分块。
+
+
+
+
+ {searchModeLabel(searchResponse.mode)}
+
+
+
+
+ {searchResponse.results.length === 0 ? (
+
+
+
已完成 Wiki 文档检索,暂未命中相关页面
+
可以缩短关键词或换一个主题后重试。
+
+ ) : searchResponse.results.map((result) => {
+ const heading = result.matched_heading || result.matched_sections?.find((section) => section.heading)?.heading;
+ const updatedAt = formatSearchTime(result.updated_at);
+ return (
+ setSelectedSearchResult(result)}
+ >
+
+
+
+
+
+
+ Wiki 文档
+ {wikiPageTypeLabel(result.page_type)}
+ {result.knowledge_base_name}
+
+
{result.title || "未命名 Wiki 页面"}
+ {heading &&
命中章节:{heading}
}
+
{resultPreview(result)}
+
+
+ 相关度 {Math.round(Math.max(0, result.score || 0) * 100)}%
+ {updatedAt && 更新于 {updatedAt} }
+
+
+ 抽屉查看完整 Wiki
+
+
+
+
+
+ );
+ })}
+
)}
+ {
+ if (!open) setSelectedSearchResult(null);
+ }}
+ />
+
{
try {
const created = await actions.create.mutateAsync(input);
diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer.tsx
index 937935af..61a63d6e 100644
--- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer.tsx
+++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer.tsx
@@ -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();
+async function getAvailableWikiPage(
+ knowledgeBaseId: string,
+ wikiSlug: string,
+): Promise {
+ 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 {
- 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 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[] = [];
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 (
{resolvedTitle}
知识库 Wiki 页面详情
-
-
-
-
-
-
-
{resolvedTitle}
-
-
{displayType(document, wikiPage)}
- {formatDate(document?.created_at || wikiPage?.created_at) && (
-
{formatDate(document?.created_at || wikiPage?.created_at)}
- )}
- {document?.parse_status &&
{document.parse_status} }
+
+
+
+
+ {wikiPage ? : }
+
+
+
{resolvedTitle}
+
+ {displayType(document, wikiPage)}
+ {formatDate(document?.created_at || wikiPage?.created_at) && (
+ {formatDate(document?.created_at || wikiPage?.created_at)}
+ )}
+ {document?.parse_status && {document.parse_status} }
+
-
-
- {resolvedDocumentId && (
-
void downloadOriginal()} disabled={downloading}>
- {downloading ? : }
- 下载原文
+
+ {wikiPage && (
+ <>
+ void downloadWiki("md")} disabled={wikiDownload !== null}>
+ {wikiDownload === "md" ? : }
+ 下载 MD
+
+ void downloadWiki("word")} disabled={wikiDownload !== null}>
+ {wikiDownload === "word" ? : }
+ 下载 Word
+
+ {wikiWorkspacePath && (
+
+ onOpenChange(false)}
+ >
+
+ 打开所在 Wiki
+
+
+ )}
+ >
+ )}
+ {resolvedDocumentId && (
+ void downloadOriginal()} disabled={downloading}>
+ {downloading ? : }
+ 下载原文
+
+ )}
+ setFullscreen((value) => !value)}
+ aria-label={fullscreen ? "退出放大查看" : "单独放大查看文档"}
+ >
+ {fullscreen ? : }
+ {fullscreen ? "退出放大" : "放大查看"}
- )}
- setFullscreen((value) => !value)}
- aria-label={fullscreen ? "退出放大查看" : "单独放大查看文档"}
- >
- {fullscreen ? : }
- {fullscreen ? "退出放大" : "放大查看"}
-
+
{loading && (
- 正在加载 Wiki 页面
+ 正在加载完整 Wiki 文档
)}
@@ -785,13 +912,16 @@ export function WeKnoraSourceDrawer({
) : null}
- {wikiContent && (
+ {wikiDisplayContent && (
- Wiki 原文
+
+
+ Wiki 文档正文
+
= {
+ 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>(() => 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 (
+
+
+ {hasChildren ? (
+ toggleDirectory(node.key)}
+ aria-label={expanded ? "收起目录" : "展开目录"}
+ >
+ {expanded ? : }
+
+ ) : (
+
+ )}
+ {node.page ? (
+
+
+ {label}
+
+ ) : (
+ toggleDirectory(node.key)}
+ title={label}
+ >
+
+ {label}
+ {node.count}
+
+ )}
+
+ {hasChildren && expanded && (
+
{node.children.map((child) => renderDirectoryNode(child, depth + 1))}
+ )}
+
+ );
+ };
+
+ if (!wikiSlug) {
+ return (
+
+ 当前链接缺少 Wiki 页面标识
+ navigate(LIST_PATH)}>返回知识库
+
+ );
+ }
+
+ if (page.isLoading && !pageData) {
+ return (
+
+
+ 正在加载 Wiki 页面
+
+ );
+ }
+
+ if (!pageData) {
+ return (
+
+
+
+
+
+
无法打开 Wiki 页面
+
+ {page.error instanceof Error ? page.error.message : "页面不存在或当前账号无权访问"}
+
+
+
+ navigate(LIST_PATH)}>返回知识库
+ void page.refetch()}>
+ 重试
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ Wiki 目录
+
+
+ {pagesList.length} 篇
+
+
+
+
+ setDirectoryQuery(event.target.value)}
+ placeholder="搜索目录或页面"
+ className="h-9 bg-muted/35 pl-9"
+ aria-label="搜索 Wiki 目录"
+ />
+
+
+
+ {pages.isLoading && pagesList.length === 0 ? (
+
+
+ 正在加载目录
+
+ ) : wikiTree.roots.length === 0 ? (
+
+ {directoryQuery.trim() ? "没有匹配的 Wiki 页面" : "暂无 Wiki 目录"}
+
+ ) : (
+
+ {wikiTree.roots.map((node) => renderDirectoryNode(node))}
+
+ )}
+
+
+
+
+
+ {summary && (
+
+ )}
+
+
+
+
+
+ );
+}
+
+export default WeKnoraWikiPageView;
diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md
index 8c009277..da4f24ac 100644
--- a/offline-backend-20260512/backend/CLAUDE.md
+++ b/offline-backend-20260512/backend/CLAUDE.md
@@ -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
diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md
index 6b1f6519..f4a769ee 100644
--- a/offline-backend-20260512/backend/README.md
+++ b/offline-backend-20260512/backend/README.md
@@ -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