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