feat: improve wiki document and artifact views
This commit is contained in:
1 parent
3785345353
commit
49babb6d25
23 files changed
+1728
-329
No files matched your search
@@ -0,0 +1,226 @@
|
|||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
FolderIcon,
|
||||||
|
FolderOpenIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { artifactPathname, artifactRelativePath } from "@/core/artifacts/listing";
|
||||||
|
import { getFileIcon, getFileName } from "@/core/utils/files";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type DirectoryNode = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
directories: DirectoryNode[];
|
||||||
|
files: Array<{ artifact: string; name: string; relativePath: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildDirectoryTree(artifacts: string[]): DirectoryNode {
|
||||||
|
const root: DirectoryNode = {
|
||||||
|
name: "生成文件",
|
||||||
|
path: "",
|
||||||
|
directories: [],
|
||||||
|
files: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const artifact of artifacts) {
|
||||||
|
const relativePath = artifactRelativePath(artifact);
|
||||||
|
const parts = relativePath.split("/").filter(Boolean);
|
||||||
|
if (parts.length === 0) continue;
|
||||||
|
|
||||||
|
let current = root;
|
||||||
|
for (const directoryName of parts.slice(0, -1)) {
|
||||||
|
let child = current.directories.find((node) => node.name === directoryName);
|
||||||
|
if (!child) {
|
||||||
|
child = {
|
||||||
|
name: directoryName,
|
||||||
|
path: current.path ? `${current.path}/${directoryName}` : directoryName,
|
||||||
|
directories: [],
|
||||||
|
files: [],
|
||||||
|
};
|
||||||
|
current.directories.push(child);
|
||||||
|
}
|
||||||
|
current = child;
|
||||||
|
}
|
||||||
|
|
||||||
|
current.files.push({
|
||||||
|
artifact,
|
||||||
|
name: parts.at(-1) ?? getFileName(artifact),
|
||||||
|
relativePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sort = (node: DirectoryNode) => {
|
||||||
|
node.directories.sort((left, right) =>
|
||||||
|
left.name.localeCompare(right.name, "zh-CN"),
|
||||||
|
);
|
||||||
|
node.files.sort((left, right) =>
|
||||||
|
left.name.localeCompare(right.name, "zh-CN"),
|
||||||
|
);
|
||||||
|
node.directories.forEach(sort);
|
||||||
|
};
|
||||||
|
sort(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirectoryBranch({
|
||||||
|
node,
|
||||||
|
depth,
|
||||||
|
collapsedDirectories,
|
||||||
|
onToggleDirectory,
|
||||||
|
selectedArtifact,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
node: DirectoryNode;
|
||||||
|
depth: number;
|
||||||
|
collapsedDirectories: Set<string>;
|
||||||
|
onToggleDirectory: (path: string) => void;
|
||||||
|
selectedArtifact: string | null;
|
||||||
|
onSelect: (artifact: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{node.directories.map((directory) => {
|
||||||
|
const collapsed = collapsedDirectories.has(directory.path);
|
||||||
|
return (
|
||||||
|
<div key={directory.path}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:bg-muted/70 hover:text-foreground flex h-8 w-full items-center gap-1.5 rounded-md pr-2 text-left text-xs"
|
||||||
|
style={{ paddingLeft: `${8 + depth * 14}px` }}
|
||||||
|
onClick={() => onToggleDirectory(directory.path)}
|
||||||
|
title={directory.path}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronRightIcon className="size-3.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDownIcon className="size-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
{collapsed ? (
|
||||||
|
<FolderIcon className="size-4 shrink-0 text-amber-500" />
|
||||||
|
) : (
|
||||||
|
<FolderOpenIcon className="size-4 shrink-0 text-amber-500" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{directory.name}</span>
|
||||||
|
</button>
|
||||||
|
{!collapsed && (
|
||||||
|
<DirectoryBranch
|
||||||
|
node={directory}
|
||||||
|
depth={depth + 1}
|
||||||
|
collapsedDirectories={collapsedDirectories}
|
||||||
|
onToggleDirectory={onToggleDirectory}
|
||||||
|
selectedArtifact={selectedArtifact}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{node.files.map((file) => {
|
||||||
|
const selected =
|
||||||
|
!!selectedArtifact &&
|
||||||
|
artifactPathname(selectedArtifact) === artifactPathname(file.artifact);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={file.relativePath}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/70 flex min-h-8 w-full items-center gap-2 rounded-md py-1 pr-2 text-left text-xs",
|
||||||
|
selected
|
||||||
|
? "bg-primary/10 text-primary font-medium"
|
||||||
|
: "text-foreground/85",
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${27 + depth * 14}px` }}
|
||||||
|
onClick={() => onSelect(file.artifact)}
|
||||||
|
title={file.relativePath}
|
||||||
|
aria-current={selected ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<span className="shrink-0">{getFileIcon(file.artifact, "size-4")}</span>
|
||||||
|
<span className="truncate">{file.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArtifactDirectory({
|
||||||
|
className,
|
||||||
|
artifacts,
|
||||||
|
selectedArtifact,
|
||||||
|
onSelect,
|
||||||
|
onRefresh,
|
||||||
|
refreshing = false,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
artifacts: string[];
|
||||||
|
selectedArtifact: string | null;
|
||||||
|
onSelect: (artifact: string) => void;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
refreshing?: boolean;
|
||||||
|
}) {
|
||||||
|
const tree = useMemo(() => buildDirectoryTree(artifacts), [artifacts]);
|
||||||
|
const [collapsedDirectories, setCollapsedDirectories] = useState<Set<string>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleDirectory = (path: string) => {
|
||||||
|
setCollapsedDirectories((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(path)) next.delete(path);
|
||||||
|
else next.add(path);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/20 flex min-h-0 w-48 shrink-0 flex-col border-r xl:w-56",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label="生成文件目录"
|
||||||
|
>
|
||||||
|
<div className="flex h-12 shrink-0 items-center justify-between gap-2 border-b px-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold">生成文件</div>
|
||||||
|
<div className="text-muted-foreground text-[11px]">{artifacts.length} 个文件</div>
|
||||||
|
</div>
|
||||||
|
{onRefresh && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-7 shrink-0"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={refreshing}
|
||||||
|
title="刷新文件目录"
|
||||||
|
aria-label="刷新文件目录"
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className={cn("size-3.5", refreshing && "animate-spin")} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||||
|
{artifacts.length > 0 ? (
|
||||||
|
<DirectoryBranch
|
||||||
|
node={tree}
|
||||||
|
depth={0}
|
||||||
|
collapsedDirectories={collapsedDirectories}
|
||||||
|
onToggleDirectory={toggleDirectory}
|
||||||
|
selectedArtifact={selectedArtifact}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground px-2 py-8 text-center text-xs leading-5">
|
||||||
|
生成的文件会显示在这里
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,11 +52,17 @@ interface ArtifactsProviderProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||||
const [artifacts, setArtifactsState] = useState<string[]>([]);
|
const [artifacts, setArtifactsState] = useState<string[]>([]);
|
||||||
const setArtifacts = useCallback((next: 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 [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
|
||||||
const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
|
const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
|
||||||
const [autoSelect, setAutoSelect] = useState(true);
|
const [autoSelect, setAutoSelect] = useState(true);
|
||||||
@@ -99,38 +105,43 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
[sidebar],
|
[sidebar],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deselect = useCallback(() => {
|
const deselect = useCallback(() => {
|
||||||
setSelectedArtifact(null);
|
setSelectedArtifact(null);
|
||||||
setSelectedArtifactKey(null);
|
setSelectedArtifactKey(null);
|
||||||
setAutoSelect(true);
|
setAutoSelect(true);
|
||||||
setOpenState(false);
|
setOpenState(false);
|
||||||
suppressedAutoOpenKeyRef.current = null;
|
suppressedAutoOpenKeyRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value: ArtifactsContextType = {
|
const setOpen = useCallback(
|
||||||
artifacts,
|
(isOpen: boolean) => {
|
||||||
setArtifacts,
|
if (!isOpen && autoOpen) {
|
||||||
|
setAutoOpen(false);
|
||||||
open,
|
setAutoSelect(false);
|
||||||
autoOpen,
|
}
|
||||||
autoSelect,
|
|
||||||
setOpen: (isOpen: boolean) => {
|
|
||||||
if (!isOpen && autoOpen) {
|
|
||||||
setAutoOpen(false);
|
|
||||||
setAutoSelect(false);
|
|
||||||
}
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
suppressedAutoOpenKeyRef.current = selectedArtifactKey;
|
suppressedAutoOpenKeyRef.current = selectedArtifactKey;
|
||||||
}
|
}
|
||||||
setOpenState(isOpen);
|
setOpenState(isOpen);
|
||||||
},
|
},
|
||||||
|
[autoOpen, selectedArtifactKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value: ArtifactsContextType = {
|
||||||
|
artifacts,
|
||||||
|
setArtifacts,
|
||||||
|
|
||||||
|
open,
|
||||||
|
autoOpen,
|
||||||
|
autoSelect,
|
||||||
|
setOpen,
|
||||||
|
|
||||||
selectedArtifact,
|
selectedArtifact,
|
||||||
selectedArtifactKey,
|
selectedArtifactKey,
|
||||||
select,
|
select,
|
||||||
openStreamedArtifact,
|
openStreamedArtifact,
|
||||||
deselect,
|
deselect,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ArtifactsContext.Provider value={value}>
|
<ArtifactsContext.Provider value={value}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./artifact-file-detail";
|
export * from "./artifact-file-detail";
|
||||||
export * from "./artifact-file-list";
|
export * from "./artifact-directory";
|
||||||
|
export * from "./artifact-file-list";
|
||||||
export * from "./artifact-trigger";
|
export * from "./artifact-trigger";
|
||||||
export * from "./context";
|
export * from "./context";
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { FilesIcon, XIcon } from "lucide-react";
|
import { FilesIcon, XIcon } from "lucide-react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -13,6 +14,10 @@ import {
|
|||||||
} from "@/components/ui/resizable";
|
} from "@/components/ui/resizable";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { apiFetch } from "@/core/api";
|
import { apiFetch } from "@/core/api";
|
||||||
|
import {
|
||||||
|
listThreadArtifacts,
|
||||||
|
mergeArtifactPaths,
|
||||||
|
} from "@/core/artifacts/listing";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||||
@@ -32,6 +37,7 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ArtifactFileDetail,
|
ArtifactFileDetail,
|
||||||
|
ArtifactDirectory,
|
||||||
ArtifactFileList,
|
ArtifactFileList,
|
||||||
useArtifacts,
|
useArtifacts,
|
||||||
} from "../artifacts";
|
} from "../artifacts";
|
||||||
@@ -40,6 +46,7 @@ import { SkillDisplaySources } from "../skill-display-sources";
|
|||||||
|
|
||||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||||
|
const DIRECTORY_OPEN_MODE = { chat: 48, artifacts: 52 };
|
||||||
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
||||||
|
|
||||||
type BackendReferenceSource = Parameters<typeof SkillDisplaySources>[0]["sources"][number];
|
type BackendReferenceSource = Parameters<typeof SkillDisplaySources>[0]["sources"][number];
|
||||||
@@ -78,7 +85,7 @@ const ChatBox: React.FC<{
|
|||||||
threadId,
|
threadId,
|
||||||
disableArtifacts = false,
|
disableArtifacts = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { thread } = useThread();
|
const { isMock, thread } = useThread();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const threadIdRef = useRef(threadId);
|
const threadIdRef = useRef(threadId);
|
||||||
const artifactsCountRef = useRef(0);
|
const artifactsCountRef = useRef(0);
|
||||||
@@ -92,6 +99,8 @@ const ChatBox: React.FC<{
|
|||||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
|
const artifactDirectoryModeEnabled =
|
||||||
|
settings.display.artifactDirectoryModeEnabled ?? false;
|
||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||||
// Frontend RAG modes write their own hidden source tool messages. They use
|
// Frontend RAG modes write their own hidden source tool messages. They use
|
||||||
@@ -110,6 +119,52 @@ const ChatBox: React.FC<{
|
|||||||
referencesEnabled && !hasFrontendRag;
|
referencesEnabled && !hasFrontendRag;
|
||||||
const { skills } = useSkills();
|
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 {
|
const {
|
||||||
artifacts,
|
artifacts,
|
||||||
open: artifactsOpen,
|
open: artifactsOpen,
|
||||||
@@ -302,7 +357,8 @@ const ChatBox: React.FC<{
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (threadIdRef.current !== threadId) {
|
const threadChanged = threadIdRef.current !== threadId;
|
||||||
|
if (threadChanged) {
|
||||||
threadIdRef.current = threadId;
|
threadIdRef.current = threadId;
|
||||||
artifactsCountRef.current = 0;
|
artifactsCountRef.current = 0;
|
||||||
deselect();
|
deselect();
|
||||||
@@ -316,15 +372,21 @@ const ChatBox: React.FC<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update artifacts from the current thread
|
// Update artifacts from the current thread
|
||||||
const nextArtifacts = disableArtifacts ? [] : (thread.values.artifacts ?? []);
|
const nextArtifacts = availableArtifacts;
|
||||||
const hadArtifacts = artifactsCountRef.current > 0;
|
const hadArtifacts = artifactsCountRef.current > 0;
|
||||||
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
|
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
|
||||||
artifactsCountRef.current = nextArtifacts.length;
|
artifactsCountRef.current = nextArtifacts.length;
|
||||||
setArtifacts(nextArtifacts);
|
const artifactsChanged =
|
||||||
|
artifacts.length !== nextArtifacts.length ||
|
||||||
|
artifacts.some((artifact, index) => artifact !== nextArtifacts[index]);
|
||||||
|
if (artifactsChanged) {
|
||||||
|
setArtifacts(nextArtifacts);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!disableArtifacts &&
|
!disableArtifacts &&
|
||||||
(hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) &&
|
nextArtifacts.length > 0 &&
|
||||||
!selectedArtifact
|
((artifactDirectoryModeEnabled && threadChanged) ||
|
||||||
|
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
|
||||||
) {
|
) {
|
||||||
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
||||||
setArtifactsOpen(true);
|
setArtifactsOpen(true);
|
||||||
@@ -351,15 +413,41 @@ const ChatBox: React.FC<{
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
threadId,
|
threadId,
|
||||||
|
artifactDirectoryModeEnabled,
|
||||||
autoSelectFirstArtifact,
|
autoSelectFirstArtifact,
|
||||||
deselect,
|
deselect,
|
||||||
selectArtifact,
|
selectArtifact,
|
||||||
selectedArtifact,
|
selectedArtifact,
|
||||||
|
artifacts,
|
||||||
setArtifacts,
|
setArtifacts,
|
||||||
thread.values.artifacts,
|
availableArtifacts,
|
||||||
disableArtifacts,
|
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(() => {
|
const artifactPanelOpen = useMemo(() => {
|
||||||
if (disableArtifacts) {
|
if (disableArtifacts) {
|
||||||
return false;
|
return false;
|
||||||
@@ -370,7 +458,10 @@ const ChatBox: React.FC<{
|
|||||||
return artifactsOpen;
|
return artifactsOpen;
|
||||||
}, [artifactsOpen, artifacts, disableArtifacts]);
|
}, [artifactsOpen, artifacts, disableArtifacts]);
|
||||||
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
|
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
|
||||||
const showTabs = !!selectedArtifact && hasCitationReferences;
|
const showTabs =
|
||||||
|
hasCitationReferences &&
|
||||||
|
(!!selectedArtifact ||
|
||||||
|
(artifactDirectoryModeEnabled && artifacts.length > 0));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedArtifact) {
|
if (selectedArtifact) {
|
||||||
@@ -390,12 +481,103 @@ const ChatBox: React.FC<{
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (layoutRef.current) {
|
if (layoutRef.current) {
|
||||||
if (sidePanelOpen) {
|
if (sidePanelOpen) {
|
||||||
layoutRef.current.setLayout(OPEN_MODE);
|
layoutRef.current.setLayout(
|
||||||
|
artifactDirectoryModeEnabled && artifactPanelOpen
|
||||||
|
? DIRECTORY_OPEN_MODE
|
||||||
|
: OPEN_MODE,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
layoutRef.current.setLayout(CLOSE_MODE);
|
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) {
|
if (disableArtifacts) {
|
||||||
return (
|
return (
|
||||||
@@ -455,13 +637,7 @@ const ChatBox: React.FC<{
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||||
<ArtifactFileDetail
|
{artifactDocumentPanel}
|
||||||
key={selectedArtifactKey ?? selectedArtifact}
|
|
||||||
className="size-full"
|
|
||||||
filepath={selectedArtifact}
|
|
||||||
threadId={threadId}
|
|
||||||
allowVirtualMarkdownEdit
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
||||||
<ReferencePanel
|
<ReferencePanel
|
||||||
@@ -470,14 +646,8 @@ const ChatBox: React.FC<{
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : selectedArtifact ? (
|
) : artifactPanelOpen ? (
|
||||||
<ArtifactFileDetail
|
artifactDocumentPanel
|
||||||
key={selectedArtifactKey ?? selectedArtifact}
|
|
||||||
className="size-full"
|
|
||||||
filepath={selectedArtifact}
|
|
||||||
threadId={threadId}
|
|
||||||
allowVirtualMarkdownEdit
|
|
||||||
/>
|
|
||||||
) : hasCitationReferences ? (
|
) : hasCitationReferences ? (
|
||||||
<div className="relative size-full overflow-hidden">
|
<div className="relative size-full overflow-hidden">
|
||||||
<div className="absolute top-1 right-1 z-30">
|
<div className="absolute top-1 right-1 z-30">
|
||||||
@@ -497,41 +667,7 @@ const ChatBox: React.FC<{
|
|||||||
sources={referenceSources}
|
sources={referenceSources}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import type { ChatStatus } from "ai";
|
|||||||
import {
|
import {
|
||||||
BrainCircuitIcon,
|
BrainCircuitIcon,
|
||||||
BookMarkedIcon,
|
BookMarkedIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
|
FolderTreeIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
LightbulbIcon,
|
LightbulbIcon,
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
@@ -368,12 +369,20 @@ export function InputBox({
|
|||||||
[onContextChange, context],
|
[onContextChange, context],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleWritingModeToggle = useCallback(() => {
|
const handleWritingModeToggle = useCallback(() => {
|
||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
writing_mode: !context.writing_mode,
|
writing_mode: !context.writing_mode,
|
||||||
});
|
});
|
||||||
}, [context, onContextChange]);
|
}, [context, onContextChange]);
|
||||||
|
|
||||||
|
const artifactDirectoryModeEnabled =
|
||||||
|
localSettings.display.artifactDirectoryModeEnabled ?? false;
|
||||||
|
const handleArtifactDirectoryModeToggle = useCallback(() => {
|
||||||
|
setLocalSettings("display", {
|
||||||
|
artifactDirectoryModeEnabled: !artifactDirectoryModeEnabled,
|
||||||
|
});
|
||||||
|
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
||||||
|
|
||||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||||
const handleMemoryInjectionToggle = useCallback(() => {
|
const handleMemoryInjectionToggle = useCallback(() => {
|
||||||
@@ -736,7 +745,7 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!frontendRagActive && <Tooltip
|
{!frontendRagActive && <Tooltip
|
||||||
content={
|
content={
|
||||||
context.writing_mode
|
context.writing_mode
|
||||||
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
|
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
|
||||||
@@ -760,9 +769,38 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
<FileTextIcon className="size-3" />
|
<FileTextIcon className="size-3" />
|
||||||
<span className="text-xs font-normal">写作</span>
|
<span className="text-xs font-normal">写作</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>}
|
</Tooltip>}
|
||||||
{!frontendRagActive && <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={
|
content={
|
||||||
memoryInjectionEnabled
|
memoryInjectionEnabled
|
||||||
? "记忆注入已开启:对话前会注入历史记忆"
|
? "记忆注入已开启:对话前会注入历史记忆"
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ function ToolCall({
|
|||||||
? null
|
? null
|
||||||
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
||||||
null);
|
null);
|
||||||
const displayItems =
|
const displayItems =
|
||||||
displayMatch && result !== undefined
|
displayMatch && result !== undefined
|
||||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -270,15 +270,21 @@ function ToolCall({
|
|||||||
skillName: displayMatch.skillName,
|
skillName: displayMatch.skillName,
|
||||||
mode: item.displayMode ?? displayMatch.display.mode,
|
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) {
|
if (displayItems.length > 0) {
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
key={id}
|
key={id}
|
||||||
label={
|
label={
|
||||||
displayMatch?.display.mode === "citation" || !displayMatch
|
wikiSearchLabel || (displayMatch?.display.mode === "citation" || !displayMatch
|
||||||
? "检索参考来源"
|
? "检索参考来源"
|
||||||
: `获取 ${displayItems.length} 条结果`
|
: `获取 ${displayItems.length} 条结果`)
|
||||||
}
|
}
|
||||||
icon={SearchIcon}
|
icon={SearchIcon}
|
||||||
>
|
>
|
||||||
@@ -303,7 +309,7 @@ function ToolCall({
|
|||||||
mode: "citation" as const,
|
mode: "citation" as const,
|
||||||
}));
|
}));
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep key={id} label="检索参考来源" icon={SearchIcon}>
|
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
||||||
<DisplayResultsList results={defaultItems} />
|
<DisplayResultsList results={defaultItems} />
|
||||||
</ChainOfThoughtStep>
|
</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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -12,9 +12,11 @@ import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
|||||||
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
|
import { reasoningPlugins } from "@/core/streamdown";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||||
|
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||||
|
|
||||||
export function SkillDisplaySources({
|
export function SkillDisplaySources({
|
||||||
highlightedReferenceId,
|
highlightedReferenceId,
|
||||||
@@ -63,8 +65,20 @@ export function SourceCard({
|
|||||||
const cleanupWords =
|
const cleanupWords =
|
||||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||||
const snippet = compactReferenceText(content).slice(0, 280);
|
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
|
||||||
const paragraphs = segmentReferenceText(content);
|
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 =
|
const score =
|
||||||
typeof source.score === "number"
|
typeof source.score === "number"
|
||||||
? source.score <= 1
|
? source.score <= 1
|
||||||
@@ -73,16 +87,22 @@ export function SourceCard({
|
|||||||
: null;
|
: null;
|
||||||
const meta = [
|
const meta = [
|
||||||
source.source,
|
source.source,
|
||||||
previewTarget?.wikiSlug && source.raw?.matched_sections
|
isWikiSource ? "检索对象:Wiki 文档" : null,
|
||||||
? `Wiki 章节 ${String((source.raw.matched_sections as Array<{ heading?: string }>)[0]?.heading || "")}`
|
matchedHeading ? `命中章节:${matchedHeading}` : null,
|
||||||
: null,
|
|
||||||
source.author ? `作者 ${source.author}` : 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,
|
source.time,
|
||||||
score ? `相关度 ${score}` : null,
|
score ? `相关度 ${score}` : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const isCitation = source.mode === "citation";
|
const isCitation = source.mode === "citation";
|
||||||
const showReferenceNumber = isCitation;
|
const showReferenceNumber = isCitation;
|
||||||
|
const openSource = () => {
|
||||||
|
if (previewTarget) {
|
||||||
|
setDetailOpen(true);
|
||||||
|
} else {
|
||||||
|
setRelatedOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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)]",
|
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
|
||||||
"cursor-pointer",
|
"cursor-pointer",
|
||||||
)}
|
)}
|
||||||
onClick={() => setRelatedOpen(true)}
|
onClick={openSource}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setRelatedOpen(true);
|
openSource();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -131,11 +151,16 @@ export function SourceCard({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{source.url && (
|
{(source.url || isWikiSource) && (
|
||||||
previewTarget ? (
|
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">
|
<span className={cn(
|
||||||
<FileTextIcon className="size-3.5" />
|
"mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium",
|
||||||
{previewTarget.wikiSlug ? "Wiki 页面" : "知识库文档"}
|
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>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
<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>
|
</div>
|
||||||
{previewTarget && (
|
{previewTarget && (
|
||||||
<div className="mt-3 flex justify-start border-t pt-2">
|
<div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground">
|
||||||
<Button
|
<span>{isWikiSource ? "点击卡片查看完整 Wiki" : "点击卡片查看详情"}</span>
|
||||||
size="sm"
|
<ChevronRightIcon className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -168,7 +183,7 @@ export function SourceCard({
|
|||||||
onOpenChange={setDetailOpen}
|
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">
|
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-start justify-between gap-3 pr-8">
|
<div className="flex items-start justify-between gap-3 pr-8">
|
||||||
@@ -205,13 +220,23 @@ export function SourceCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{paragraphs.length > 0 ? (
|
{content ? (
|
||||||
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
|
<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">
|
||||||
{paragraphs.map((paragraph, index) => (
|
{isWikiSource && (
|
||||||
<p key={index} className="whitespace-pre-wrap">
|
<div className="mb-4 flex items-center gap-2 border-b pb-3 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||||
{paragraph}
|
<BookOpenTextIcon className="size-4" />
|
||||||
</p>
|
本次检索命中的 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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{source.url && !previewTarget && (
|
{source.url && !previewTarget && (
|
||||||
@@ -220,7 +245,7 @@ export function SourceCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -249,44 +274,18 @@ function compactReferenceText(text: string): string {
|
|||||||
return text.replace(/\s+/g, " ").trim();
|
return text.replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function segmentReferenceText(text: string): string[] {
|
function plainTextReferencePreview(text: string): string {
|
||||||
const explicit = text
|
return compactReferenceText(
|
||||||
.split(/\n{2,}/)
|
text
|
||||||
.map((item) => item.trim())
|
.replace(/^---[\s\S]*?---\s*/m, "")
|
||||||
.filter(Boolean);
|
.replace(/```[\s\S]*?```/g, " ")
|
||||||
const base = explicit.length > 1 ? explicit : splitLongParagraph(text);
|
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||||
return base.slice(0, 80);
|
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||||
}
|
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
|
||||||
|
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
||||||
function splitLongParagraph(text: string): string[] {
|
.replace(/^#{1,6}\s+/gm, "")
|
||||||
const normalized = text
|
.replace(/^\s*[-*+]\s+/gm, "")
|
||||||
.split(/\n+/)
|
.replace(/^\s*\d+[.)]\s+/gm, "")
|
||||||
.map((item) => item.trim())
|
.replace(/[>*_`~|]/g, " "),
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
@@ -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.",
|
lightDescription: "Bright palette with higher contrast for daytime.",
|
||||||
darkDescription: "Dim palette that reduces glare for focus.",
|
darkDescription: "Dim palette that reduces glare for focus.",
|
||||||
lightblueDescription: "A crisp light palette built around blue.",
|
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.",
|
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
|
||||||
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
|
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
|
||||||
languageTitle: "Language",
|
languageTitle: "Language",
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ export const zhCN: Translations = {
|
|||||||
lightDescription: "更明亮的配色,适合日间使用。",
|
lightDescription: "更明亮的配色,适合日间使用。",
|
||||||
darkDescription: "更暗的配色,减少眩光方便专注。",
|
darkDescription: "更暗的配色,减少眩光方便专注。",
|
||||||
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
||||||
lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
|
lightgreenDescription: "采用清新绿色的浅色配色。",
|
||||||
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
|
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
|
||||||
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
|
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
|
||||||
languageTitle: "语言",
|
languageTitle: "语言",
|
||||||
|
|||||||
@@ -95,6 +95,25 @@ export function buildWeKnoraSourceIframeUrl(target: WeKnoraSourceTarget): string
|
|||||||
return `/#/page/workspace/knowledge/${encodeURIComponent(target.knowledgeBaseId)}${query ? `?${query}` : ""}`;
|
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 {
|
export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): string {
|
||||||
const recUuid = source.recUuid || source.rec_uuid || source.url || "";
|
const recUuid = source.recUuid || source.rec_uuid || source.url || "";
|
||||||
if (!recUuid) return "";
|
if (!recUuid) return "";
|
||||||
@@ -109,7 +128,9 @@ export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): stri
|
|||||||
export function openKnowledgeSource(source: KnowledgeSourceLike): boolean {
|
export function openKnowledgeSource(source: KnowledgeSourceLike): boolean {
|
||||||
const weknoraTarget = resolveWeKnoraSourceTarget(source);
|
const weknoraTarget = resolveWeKnoraSourceTarget(source);
|
||||||
const href = weknoraTarget
|
const href = weknoraTarget
|
||||||
? buildWeKnoraSourceIframeUrl(weknoraTarget)
|
? weknoraTarget.wikiSlug
|
||||||
|
? buildWeKnoraWikiPageUrl(weknoraTarget.knowledgeBaseId, weknoraTarget.wikiSlug)
|
||||||
|
: buildWeKnoraSourceIframeUrl(weknoraTarget)
|
||||||
: buildLegacyKnowledgeSourceUrl(source);
|
: buildLegacyKnowledgeSourceUrl(source);
|
||||||
if (!href) return false;
|
if (!href) return false;
|
||||||
window.open(href, "_blank", "noopener,noreferrer");
|
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 };
|
||||||
|
}
|
||||||
@@ -4,10 +4,11 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
|||||||
notification: {
|
notification: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
streamSpeedMeterEnabled: true,
|
streamSpeedMeterEnabled: true,
|
||||||
ragModeEnabled: false,
|
ragModeEnabled: false,
|
||||||
contextCompressionEnabled: false,
|
artifactDirectoryModeEnabled: false,
|
||||||
|
contextCompressionEnabled: false,
|
||||||
citationCleanupWords: [],
|
citationCleanupWords: [],
|
||||||
fontScale: 1,
|
fontScale: 1,
|
||||||
},
|
},
|
||||||
@@ -43,7 +44,12 @@ export interface LocalSettings {
|
|||||||
* Global switch for skill result display adapters. When disabled, chat
|
* Global switch for skill result display adapters. When disabled, chat
|
||||||
* messages render tool results with the legacy generic search UI only.
|
* messages render tool results with the legacy generic search UI only.
|
||||||
*/
|
*/
|
||||||
ragModeEnabled: boolean;
|
ragModeEnabled: boolean;
|
||||||
|
/**
|
||||||
|
* When enabled, the artifact panel scans every generated output and keeps
|
||||||
|
* a directory tree visible beside the selected file preview.
|
||||||
|
*/
|
||||||
|
artifactDirectoryModeEnabled: boolean;
|
||||||
/**
|
/**
|
||||||
* Per-user switch for context compression (上下文压缩). When enabled, long
|
* Per-user switch for context compression (上下文压缩). When enabled, long
|
||||||
* conversations are summarized transiently before the model call to save
|
* conversations are summarized transiently before the model call to save
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Loader2, Settings2 } from "lucide-react";
|
import { Loader2, Settings2 } from "lucide-react";
|
||||||
import { useState } from "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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
|
||||||
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||||
import WeKnoraKnowledgeWorkspace, {
|
import WeKnoraKnowledgeWorkspace, {
|
||||||
ConnectionSettings,
|
ConnectionSettings,
|
||||||
@@ -66,7 +67,14 @@ export default function LlmWikiKnowledgePage({
|
|||||||
}: {
|
}: {
|
||||||
mode?: LlmWikiKnowledgePageMode;
|
mode?: LlmWikiKnowledgePageMode;
|
||||||
}) {
|
}) {
|
||||||
|
const { noteId = "" } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const runtime = useLlmWikiRuntime();
|
const runtime = useLlmWikiRuntime();
|
||||||
|
const wikiSlug = searchParams.get("wikiSlug")?.trim() || "";
|
||||||
|
|
||||||
|
if (mode === "detail" && noteId && wikiSlug) {
|
||||||
|
return <Navigate to={buildWeKnoraWikiPagePath(noteId, wikiSlug)} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
if (runtime.isLoading) {
|
if (runtime.isLoading) {
|
||||||
return <RuntimeLoading />;
|
return <RuntimeLoading />;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
|
|||||||
import ScheduledTasksPage from './ScheduledTasksPage';
|
import ScheduledTasksPage from './ScheduledTasksPage';
|
||||||
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
|
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
|
||||||
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
|
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 MarkdownDocxEditorPage from './MarkdownDocxEditorPage';
|
||||||
import LlmMetricsPage from './LlmMetricsPage';
|
import LlmMetricsPage from './LlmMetricsPage';
|
||||||
import CheckpointMigrationPage from './CheckpointMigrationPage';
|
import CheckpointMigrationPage from './CheckpointMigrationPage';
|
||||||
@@ -658,6 +659,14 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="knowledge/:noteId/wiki/view"
|
||||||
|
element={
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<WeKnoraWikiPageView />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="knowledge/:noteId/wiki"
|
path="knowledge/:noteId/wiki"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -175,16 +175,38 @@ export interface LlmWikiSourceContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LlmWikiSearchResult {
|
export interface LlmWikiSearchResult {
|
||||||
chunk_id: string;
|
kind: "wiki_page";
|
||||||
|
id: string;
|
||||||
|
wiki_page_id: string;
|
||||||
|
wiki_slug: string;
|
||||||
content: string;
|
content: string;
|
||||||
knowledge_id: string;
|
|
||||||
knowledge_base_id: string;
|
knowledge_base_id: string;
|
||||||
knowledge_base_name: string;
|
knowledge_base_name: string;
|
||||||
title: 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;
|
score: number;
|
||||||
chunk_index: number;
|
|
||||||
url: string;
|
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 {
|
export interface LlmWikiWeKnoraEmbedSession {
|
||||||
@@ -219,6 +241,10 @@ export interface LlmWikiWikiPageUpdate {
|
|||||||
wiki_path?: string;
|
wiki_path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function userFacingKnowledgeMessage(value: string): string {
|
||||||
|
return value.replace(/weknora/gi, "知识服务");
|
||||||
|
}
|
||||||
|
|
||||||
async function readJson<T>(response: Response, context: string): Promise<T> {
|
async function readJson<T>(response: Response, context: string): Promise<T> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const payload = await response.json().catch(() => null as unknown);
|
const payload = await response.json().catch(() => null as unknown);
|
||||||
@@ -230,7 +256,7 @@ async function readJson<T>(response: Response, context: string): Promise<T> {
|
|||||||
: rawDetail
|
: rawDetail
|
||||||
? String(rawDetail)
|
? String(rawDetail)
|
||||||
: `${context}失败(${response.status})`;
|
: `${context}失败(${response.status})`;
|
||||||
throw new Error(detail);
|
throw new Error(userFacingKnowledgeMessage(detail));
|
||||||
}
|
}
|
||||||
if (response.status === 204) return undefined as T;
|
if (response.status === 204) return undefined as T;
|
||||||
return (await response.json()) 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)}`,
|
`${API()}/knowledge-bases/${encodeURIComponent(id)}/weknora/session?tab=${encodeURIComponent(tab)}`,
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
),
|
),
|
||||||
"创建 WeKnora 详情会话",
|
"创建知识库详情会话",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,8 +660,8 @@ export async function searchLlmWiki(input: {
|
|||||||
query: string;
|
query: string;
|
||||||
scope: LlmWikiScope;
|
scope: LlmWikiScope;
|
||||||
knowledge_base_ids?: string[];
|
knowledge_base_ids?: string[];
|
||||||
}): Promise<LlmWikiSearchResult[]> {
|
}): Promise<LlmWikiSearchResponse> {
|
||||||
const payload = await readJson<{ results: LlmWikiSearchResult[] }>(
|
return readJson<LlmWikiSearchResponse>(
|
||||||
await apiFetch(`${API()}/search`, {
|
await apiFetch(`${API()}/search`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -643,7 +669,6 @@ export async function searchLlmWiki(input: {
|
|||||||
}),
|
}),
|
||||||
"检索知识库",
|
"检索知识库",
|
||||||
);
|
);
|
||||||
return payload.results ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createLlmWikiConversationDeposit(input: {
|
export async function createLlmWikiConversationDeposit(input: {
|
||||||
@@ -666,7 +691,7 @@ export async function createLlmWikiConversationDeposit(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getLlmWikiSettings(): Promise<LlmWikiSettings> {
|
export async function getLlmWikiSettings(): Promise<LlmWikiSettings> {
|
||||||
return readJson(await apiFetch(SETTINGS_API()), "读取 WeKnora 连接配置");
|
return readJson(await apiFetch(SETTINGS_API()), "读取知识服务连接配置");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveLlmWikiSettings(input: {
|
export async function saveLlmWikiSettings(input: {
|
||||||
@@ -680,7 +705,7 @@ export async function saveLlmWikiSettings(input: {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
}),
|
}),
|
||||||
"保存 WeKnora 连接配置",
|
"保存知识服务连接配置",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -700,7 +725,7 @@ export async function testLlmWikiConnection(
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ api_base_url: apiBaseUrl }),
|
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 { useEffect, useRef, useState } from "react";
|
||||||
import { Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { MarkdownCodeEditor } from "@/components/wiki/MarkdownCodeEditor";
|
import { MarkdownCodeEditor } from "@/components/wiki/MarkdownCodeEditor";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
|
||||||
import {
|
import {
|
||||||
createLlmWikiWeKnoraEmbedSession,
|
createLlmWikiWeKnoraEmbedSession,
|
||||||
getLlmWikiSourceContext,
|
getLlmWikiSourceContext,
|
||||||
@@ -100,14 +101,11 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [frameSrc, setFrameSrc] = useState("");
|
const [frameSrc, setFrameSrc] = useState("");
|
||||||
const [frameVersion, setFrameVersion] = useState(0);
|
const [frameVersion, setFrameVersion] = useState(0);
|
||||||
const [frameError, setFrameError] = useState<string | null>(null);
|
const [frameError, setFrameError] = useState<string | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const runtime = useLlmWikiRuntime();
|
const runtime = useLlmWikiRuntime();
|
||||||
const details = useLlmWikiKnowledgeBase(knowledgeBaseId || null, !!knowledgeBaseId);
|
const details = useLlmWikiKnowledgeBase(knowledgeBaseId || null, !!knowledgeBaseId);
|
||||||
const actions = useLlmWikiActions();
|
|
||||||
|
|
||||||
const detailsReady = !!details.data;
|
const detailsReady = !!details.data;
|
||||||
const requestedTab = searchParams.get("tab");
|
const requestedTab = searchParams.get("tab");
|
||||||
@@ -120,12 +118,15 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
const sourceChunkId = searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
|
const sourceChunkId = searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
|
||||||
const documentId = searchParams.get("documentId") || "";
|
const documentId = searchParams.get("documentId") || "";
|
||||||
const wikiSlug = searchParams.get("wikiSlug") || "";
|
const wikiSlug = searchParams.get("wikiSlug") || "";
|
||||||
|
const nativeWikiPageUrl = knowledgeBaseId && wikiSlug && preferredTab === "wiki"
|
||||||
|
? buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)
|
||||||
|
: "";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setFrameSrc("");
|
setFrameSrc("");
|
||||||
setFrameError(null);
|
setFrameError(null);
|
||||||
if (!knowledgeBaseId || runtime.data?.provider !== "weknora" || !detailsReady) {
|
if (nativeWikiPageUrl || !knowledgeBaseId || runtime.data?.provider !== "weknora" || !detailsReady) {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
@@ -158,7 +159,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [detailsReady, documentId, knowledgeBaseId, preferredTab, runtime.data?.provider, sourceChunkId, wikiSlug]);
|
}, [detailsReady, documentId, knowledgeBaseId, nativeWikiPageUrl, preferredTab, runtime.data?.provider, sourceChunkId, wikiSlug]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!frameSrc) return;
|
if (!frameSrc) return;
|
||||||
@@ -178,6 +179,10 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
};
|
};
|
||||||
}, [frameSrc]);
|
}, [frameSrc]);
|
||||||
|
|
||||||
|
if (nativeWikiPageUrl) {
|
||||||
|
return <Navigate to={nativeWikiPageUrl} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
if (runtime.isLoading || details.isLoading) {
|
if (runtime.isLoading || details.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
<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 =
|
const canEditCurrentWikiArticle =
|
||||||
canUpload && !!details.data.indexing_strategy?.wiki_enabled && !!wikiSlug;
|
canWrite && !!details.data.indexing_strategy?.wiki_enabled && !!wikiSlug;
|
||||||
const canManageWiki = canUpload && !!details.data.indexing_strategy?.wiki_enabled;
|
const canManageWiki = canWrite && !!details.data.indexing_strategy?.wiki_enabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
<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">
|
<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">
|
<div className="min-w-0 flex-1">
|
||||||
{canEditCurrentWikiArticle && (
|
{canEditCurrentWikiArticle && (
|
||||||
<CurrentWikiArticleActions
|
<CurrentWikiArticleActions
|
||||||
@@ -288,7 +255,6 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{canManageWiki && (
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -297,22 +263,6 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
<FileText className="mr-2 size-4" />
|
<FileText className="mr-2 size-4" />
|
||||||
Wiki管理
|
Wiki管理
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -323,7 +273,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
title={`${details.data.name || "知识库"} 详情`}
|
title={`${details.data.name || "知识库"} 详情`}
|
||||||
src={frameSrc}
|
src={frameSrc}
|
||||||
className="h-full min-h-0 w-full flex-1 border-0 bg-background"
|
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"
|
referrerPolicy="no-referrer"
|
||||||
onLoad={() => postThemeToFrame(frameRef.current)}
|
onLoad={() => postThemeToFrame(frameRef.current)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+141
-15
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
BookOpenText,
|
||||||
|
ChevronRight,
|
||||||
Database,
|
Database,
|
||||||
FileText,
|
FileText,
|
||||||
Globe2,
|
Globe2,
|
||||||
@@ -29,8 +31,11 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||||
import {
|
import {
|
||||||
type LlmWikiKnowledgeBase,
|
type LlmWikiKnowledgeBase,
|
||||||
|
type LlmWikiSearchResponse,
|
||||||
|
type LlmWikiSearchResult,
|
||||||
type LlmWikiScope,
|
type LlmWikiScope,
|
||||||
searchLlmWiki,
|
searchLlmWiki,
|
||||||
useLlmWikiActions,
|
useLlmWikiActions,
|
||||||
@@ -53,6 +58,49 @@ function messageOf(error: unknown): string {
|
|||||||
return error instanceof Error ? error.message : "操作失败,请稍后重试";
|
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() {
|
export function WeKnoraKnowledgeWorkspace() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
@@ -63,15 +111,13 @@ export function WeKnoraKnowledgeWorkspace() {
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<Array<{
|
const [searchResponse, setSearchResponse] = useState<LlmWikiSearchResponse | null>(null);
|
||||||
chunk_id: string;
|
const [selectedSearchResult, setSelectedSearchResult] = useState<LlmWikiSearchResult | null>(null);
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
score: number;
|
|
||||||
knowledge_base_name: string;
|
|
||||||
}>>([]);
|
|
||||||
|
|
||||||
useEffect(() => setSearchResults([]), [tab]);
|
useEffect(() => {
|
||||||
|
setSearchResponse(null);
|
||||||
|
setSelectedSearchResult(null);
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
const busy = actions.create.isPending || actions.remove.isPending || actions.publication.isPending;
|
const busy = actions.create.isPending || actions.remove.isPending || actions.publication.isPending;
|
||||||
const openDetails = (item: LlmWikiKnowledgeBase) => {
|
const openDetails = (item: LlmWikiKnowledgeBase) => {
|
||||||
@@ -87,7 +133,7 @@ export function WeKnoraKnowledgeWorkspace() {
|
|||||||
if (!query) return;
|
if (!query) return;
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
try {
|
try {
|
||||||
setSearchResults(await searchLlmWiki({ query, scope }));
|
setSearchResponse(await searchLlmWiki({ query, scope }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(messageOf(error));
|
toast.error(messageOf(error));
|
||||||
} finally {
|
} 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="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">
|
<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" />
|
<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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -166,15 +212,95 @@ export function WeKnoraKnowledgeWorkspace() {
|
|||||||
{isAdmin && <TabsContent value="settings" className="mt-5"><ConnectionSettings /></TabsContent>}
|
{isAdmin && <TabsContent value="settings" className="mt-5"><ConnectionSettings /></TabsContent>}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{searchResults.length > 0 && tab !== "settings" && (
|
{searchResponse && tab !== "settings" && (
|
||||||
<Card className="mt-5">
|
<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="pb-3"><CardTitle className="text-base">检索结果</CardTitle><CardDescription>结果由知识服务检索返回,问答中使用同一授权链路。</CardDescription></CardHeader>
|
<CardHeader className="border-b border-emerald-200/60 pb-4 dark:border-emerald-900/50">
|
||||||
<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>
|
<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>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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) => {
|
<CreateKnowledgeDialog open={createOpen} pending={actions.create.isPending} onOpenChange={setCreateOpen} onCreate={async (input) => {
|
||||||
try {
|
try {
|
||||||
const created = await actions.create.mutateAsync(input);
|
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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { reasoningPlugins } from "@/core/streamdown";
|
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 {
|
import {
|
||||||
getLlmWikiDocument,
|
getLlmWikiDocument,
|
||||||
getLlmWikiDocumentPreview,
|
getLlmWikiDocumentPreview,
|
||||||
|
getLlmWikiWikiIndexStatus,
|
||||||
|
getLlmWikiWikiPage,
|
||||||
getLocalLlmWikiWikiPage,
|
getLocalLlmWikiWikiPage,
|
||||||
listLlmWikiDocumentChunks,
|
listLlmWikiDocumentChunks,
|
||||||
type LlmWikiChunk,
|
type LlmWikiChunk,
|
||||||
@@ -61,8 +81,34 @@ type CachedSourceDetail = {
|
|||||||
|
|
||||||
const sourceDetailCache = new Map<string, 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> {
|
async function resolveTargetDetail(target: WeKnoraSourceTarget): Promise<DetailTarget> {
|
||||||
if (target.documentId || target.wikiSlug || !target.chunkId) {
|
if (target.wikiSlug || !target.chunkId) {
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
const params = new URLSearchParams({
|
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;
|
const payload = (await response.json().catch(() => null)) as SourceContextResponse | null;
|
||||||
if (!response.ok || payload?.success !== true) {
|
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 documentId = payload.data?.document_id?.trim();
|
||||||
const wikiSlug = payload.data?.wiki_page?.slug?.trim();
|
const wikiSlug = payload.data?.wiki_page?.slug?.trim();
|
||||||
if (!documentId && !wikiSlug) {
|
if (!documentId && !wikiSlug) {
|
||||||
throw new Error("当前引用缺少原文文档标识,无法打开详情");
|
if (target.documentId) return target;
|
||||||
|
throw new Error("当前引用缺少 Wiki 页面或文档标识,无法打开详情");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...target,
|
...target,
|
||||||
@@ -96,8 +144,14 @@ function formatDate(value?: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function displayType(document?: LlmWikiDocument | null, wiki?: LlmWikiWikiPage | 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 (
|
return (
|
||||||
wiki?.page_type ||
|
|
||||||
document?.file_type ||
|
document?.file_type ||
|
||||||
document?.type ||
|
document?.type ||
|
||||||
document?.source ||
|
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();
|
return (value || "").replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripFrontmatter(value: string) {
|
function downloadMarkdown(markdown: string, fileName: string) {
|
||||||
return value.replace(/^\s*---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, "").trim();
|
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) {
|
function fileExtension(document?: LlmWikiDocument | null) {
|
||||||
@@ -183,7 +252,7 @@ function isWordDocument(document?: LlmWikiDocument | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function markdownFromChunks(chunks: LlmWikiChunk[]) {
|
function markdownFromChunks(chunks: LlmWikiChunk[]) {
|
||||||
return stripFrontmatter(
|
return stripWikiFrontmatter(
|
||||||
chunks.map((chunk) => cleanText(chunk.content)).filter(Boolean).join("\n\n"),
|
chunks.map((chunk) => cleanText(chunk.content)).filter(Boolean).join("\n\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -468,6 +537,7 @@ export function WeKnoraSourceDrawer({
|
|||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [wikiDownload, setWikiDownload] = useState<"md" | "word" | null>(null);
|
||||||
const [resolvedDocumentId, setResolvedDocumentId] = useState("");
|
const [resolvedDocumentId, setResolvedDocumentId] = useState("");
|
||||||
|
|
||||||
const targetKey = useMemo(() => {
|
const targetKey = useMemo(() => {
|
||||||
@@ -518,7 +588,9 @@ export function WeKnoraSourceDrawer({
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const detailTarget = await resolveTargetDetail(target);
|
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>[] = [];
|
const tasks: Promise<void>[] = [];
|
||||||
let loadedDocument: LlmWikiDocument | null = null;
|
let loadedDocument: LlmWikiDocument | null = null;
|
||||||
let loadedWikiPage: LlmWikiWikiPage | null = null;
|
let loadedWikiPage: LlmWikiWikiPage | null = null;
|
||||||
@@ -528,29 +600,23 @@ export function WeKnoraSourceDrawer({
|
|||||||
|
|
||||||
if (detailTarget.wikiSlug) {
|
if (detailTarget.wikiSlug) {
|
||||||
tasks.push(
|
tasks.push(
|
||||||
getLocalLlmWikiWikiPage(detailTarget.knowledgeBaseId, detailTarget.wikiSlug)
|
getAvailableWikiPage(detailTarget.knowledgeBaseId, detailTarget.wikiSlug)
|
||||||
.then((page) => {
|
.then((page) => {
|
||||||
loadedWikiPage = page;
|
loadedWikiPage = page;
|
||||||
if (!cancelled) setWikiPage(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(
|
loadedDocument = await getLlmWikiDocument(
|
||||||
detailTarget.knowledgeBaseId,
|
detailTarget.knowledgeBaseId,
|
||||||
detailTarget.documentId,
|
previewDocumentId,
|
||||||
);
|
);
|
||||||
if (!cancelled) setDocument(loadedDocument);
|
if (!cancelled) setDocument(loadedDocument);
|
||||||
loadedChunks = await listLlmWikiDocumentChunks(
|
loadedChunks = await listLlmWikiDocumentChunks(
|
||||||
detailTarget.knowledgeBaseId,
|
detailTarget.knowledgeBaseId,
|
||||||
detailTarget.documentId,
|
previewDocumentId,
|
||||||
1,
|
1,
|
||||||
200,
|
200,
|
||||||
)
|
)
|
||||||
@@ -562,7 +628,7 @@ export function WeKnoraSourceDrawer({
|
|||||||
}
|
}
|
||||||
const previewBlobPromise = isWordDocument(loadedDocument)
|
const previewBlobPromise = isWordDocument(loadedDocument)
|
||||||
? Promise.resolve(new Blob())
|
? Promise.resolve(new Blob())
|
||||||
: getLlmWikiDocumentPreview(detailTarget.knowledgeBaseId, detailTarget.documentId);
|
: getLlmWikiDocumentPreview(detailTarget.knowledgeBaseId, previewDocumentId);
|
||||||
const previewTask = previewBlobPromise
|
const previewTask = previewBlobPromise
|
||||||
.then((blob) => {
|
.then((blob) => {
|
||||||
return buildPreviewState(
|
return buildPreviewState(
|
||||||
@@ -590,11 +656,11 @@ export function WeKnoraSourceDrawer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
throw new Error("当前引用缺少原文文档标识,无法打开详情");
|
throw new Error("当前引用缺少 Wiki 页面或文档标识,无法打开详情");
|
||||||
}
|
}
|
||||||
await Promise.all(tasks);
|
await Promise.all(tasks);
|
||||||
sourceDetailCache.set(targetKey, {
|
sourceDetailCache.set(targetKey, {
|
||||||
documentId: detailTarget.documentId || "",
|
documentId: previewDocumentId,
|
||||||
document: loadedDocument,
|
document: loadedDocument,
|
||||||
wikiPage: loadedWikiPage,
|
wikiPage: loadedWikiPage,
|
||||||
preview: loadedPreview,
|
preview: loadedPreview,
|
||||||
@@ -622,7 +688,20 @@ export function WeKnoraSourceDrawer({
|
|||||||
title ||
|
title ||
|
||||||
"文档详情";
|
"文档详情";
|
||||||
const summary = cleanText(wikiPage?.summary || document?.description);
|
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 () => {
|
const downloadOriginal = async () => {
|
||||||
if (!target || !resolvedDocumentId) return;
|
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 (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
@@ -656,46 +757,72 @@ export function WeKnoraSourceDrawer({
|
|||||||
<SheetTitle className="sr-only">{resolvedTitle}</SheetTitle>
|
<SheetTitle className="sr-only">{resolvedTitle}</SheetTitle>
|
||||||
<SheetDescription className="sr-only">知识库 Wiki 页面详情</SheetDescription>
|
<SheetDescription className="sr-only">知识库 Wiki 页面详情</SheetDescription>
|
||||||
<div className="relative flex h-full min-h-0 flex-col bg-background">
|
<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="shrink-0 border-b px-5 py-4 pr-12">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-700">
|
<div className="flex min-w-64 flex-1 items-center gap-3">
|
||||||
<FileText className="size-5" />
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-700">
|
||||||
</div>
|
{wikiPage ? <BookOpenText className="size-5" /> : <FileText className="size-5" />}
|
||||||
<div className="min-w-0">
|
</div>
|
||||||
<div className="truncate text-base font-semibold text-foreground">{resolvedTitle}</div>
|
<div className="min-w-0">
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="truncate text-base font-semibold text-foreground">{resolvedTitle}</div>
|
||||||
<Badge variant="outline">{displayType(document, wikiPage)}</Badge>
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
{formatDate(document?.created_at || wikiPage?.created_at) && (
|
<Badge variant="outline">{displayType(document, wikiPage)}</Badge>
|
||||||
<span>{formatDate(document?.created_at || wikiPage?.created_at)}</span>
|
{formatDate(document?.created_at || wikiPage?.created_at) && (
|
||||||
)}
|
<span>{formatDate(document?.created_at || wikiPage?.created_at)}</span>
|
||||||
{document?.parse_status && <span>{document.parse_status}</span>}
|
)}
|
||||||
|
{document?.parse_status && <span>{document.parse_status}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
<div className="absolute top-4 right-12 flex items-center gap-2">
|
{wikiPage && (
|
||||||
{resolvedDocumentId && (
|
<>
|
||||||
<Button type="button" size="sm" variant="outline" onClick={() => void downloadOriginal()} disabled={downloading}>
|
<Button type="button" size="sm" variant="outline" onClick={() => void downloadWiki("md")} disabled={wikiDownload !== null}>
|
||||||
{downloading ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
{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>
|
||||||
)}
|
</div>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
|
<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" />
|
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||||
正在加载 Wiki 页面
|
正在加载完整 Wiki 文档
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -785,13 +912,16 @@ export function WeKnoraSourceDrawer({
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{wikiContent && (
|
{wikiDisplayContent && (
|
||||||
<section className="border-b px-5 py-4">
|
<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">
|
<div className="rounded-lg border bg-background p-5">
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
className="max-w-none"
|
className="max-w-none"
|
||||||
content={wikiContent}
|
content={wikiDisplayContent}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
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
|
per mapping, so ready libraries stay local while incomplete libraries use that
|
||||||
Wiki-only fallback.
|
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
|
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}/vectorize`; status is available through `GET
|
||||||
/api/llmwiki/knowledge-bases/{id}/wiki-index-status`. The Wiki management page
|
/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
|
`GET /api/llmwiki/knowledge-bases/{id}/wiki-index-status`. Existing chunk
|
||||||
citations remain readable while all new retrieval citations open Wiki pages.
|
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
|
Wiki management also provides **导出 Excel**. `GET
|
||||||
/api/llmwiki/knowledge-bases/{id}/wiki/export.xlsx` exports every processed Wiki
|
/api/llmwiki/knowledge-bases/{id}/wiki/export.xlsx` exports every processed Wiki
|
||||||
page, including its full directory path, current directory, parent-directory
|
page, including its full directory path, current directory, parent-directory
|
||||||
|
|||||||
Reference in new issue
Block a user