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 ( +
+ + {!collapsed && ( + + )} +
+ ); + })} + {node.files.map((file) => { + const selected = + !!selectedArtifact && + artifactPathname(selectedArtifact) === artifactPathname(file.artifact); + return ( + + ); + })} + + ); +} + +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 ? ( + + ) : ( + <> +
+ +
+ } + title="请选择文件" + description="从左侧目录中选择一个文件进行查看" + /> + + )} +
+
+ ) : selectedArtifact ? ( + + ) : ( +
+
+ +
+ {artifacts.length === 0 ? ( + } + title="No artifact selected" + description="Select an artifact to view its details" + /> + ) : ( +
+
+

Artifacts

+
+
+ +
+
+ )} +
+ ); 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} />
- ) : ( -
-
- -
- {thread.values.artifacts?.length === 0 ? ( - } - title="No artifact selected" - description="Select an artifact to view its details" - /> - ) : ( -
-
-

Artifacts

-
-
- -
-
- )} -
- )} + ) : 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 && ( -
- +
+ {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 && ( - )} - {canUpload && ( - - )}
)} @@ -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() {
- 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" /> + 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" />
- +
)}
@@ -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 ( + + ); + })} +
)}
+ { + 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 && ( - + + {wikiWorkspacePath && ( + + )} + + )} + {resolvedDocumentId && ( + + )} + - )} - +
{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 ? ( + + ) : ( + + )} + {node.page ? ( + + + {label} + + ) : ( + + )} +
+ {hasChildren && expanded && ( +
{node.children.map((child) => renderDirectoryNode(child, depth + 1))}
+ )} +
+ ); + }; + + if (!wikiSlug) { + return ( +
+ 当前链接缺少 Wiki 页面标识 + +
+ ); + } + + if (page.isLoading && !pageData) { + return ( +
+ + 正在加载 Wiki 页面 +
+ ); + } + + if (!pageData) { + return ( +
+
+ +
+
+
无法打开 Wiki 页面
+
+ {page.error instanceof Error ? page.error.message : "页面不存在或当前账号无权访问"} +
+
+
+ + +
+
+ ); + } + + return ( +
+
+
+ +
+ +
+
+

{title}

+
+ {pageTypeLabel(pageData.page_type)} + {knowledgeBase.data?.name || "知识库 Wiki"} + {formatDate(pageData.updated_at || pageData.created_at) && ( + 更新于 {formatDate(pageData.updated_at || pageData.created_at)} + )} +
+
+
+
+ + +
+
+ +
+ + +
+
+ {summary && ( +
+
页面摘要
+

{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