Merge branch 'ji' into master-zl
This commit is contained in:
commit
32dbdca480
107 files changed
+12486
-3151
No files matched your search
+22
-11
@@ -38,6 +38,7 @@ import EmbedChatPage from "./pages/EmbedChatPage";
|
||||
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
||||
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
|
||||
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
||||
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
|
||||
import {
|
||||
isPositionRoundtableStandaloneMode,
|
||||
POSITION_ROUNDTABLE_ROUTE,
|
||||
@@ -45,7 +46,12 @@ import {
|
||||
import { ChatRuntime } from "./pages/WorkspaceRoutes";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
|
||||
const PUBLIC_EMBED_PREFIXES = ["/share/", "/public/", "/roundtable-share/"];
|
||||
const PUBLIC_EMBED_PREFIXES = [
|
||||
"/share/",
|
||||
"/public/",
|
||||
"/roundtable-share/",
|
||||
"/embed/knowledge/",
|
||||
];
|
||||
|
||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
const locale = useMemo(() => detectLocale(), []);
|
||||
@@ -102,16 +108,17 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
forcedTheme={forcedTheme}
|
||||
>
|
||||
<QueryClientProvider>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
{!positionStandaloneMode && <AuthSyncWatcher />}
|
||||
{!positionStandaloneMode && <DesensitizeWordsLoader />}
|
||||
<FontScaleApplier />
|
||||
{children}
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
<AuthSyncWatcher disabled={positionStandaloneMode}>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
{!positionStandaloneMode && <DesensitizeWordsLoader />}
|
||||
<FontScaleApplier />
|
||||
{children}
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</AuthSyncWatcher>
|
||||
</QueryClientProvider>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
@@ -188,6 +195,10 @@ export function App() {
|
||||
}
|
||||
/>
|
||||
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
|
||||
<Route
|
||||
path="/embed/knowledge/:noteId"
|
||||
element={<WeKnoraWikiPageView publicEmbed />}
|
||||
/>
|
||||
<Route
|
||||
path="/roundtable-dashboard"
|
||||
element={<DashboardStandalonePage />}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
FolderOpen,
|
||||
Gauge,
|
||||
ListTree,
|
||||
MessageSquareText,
|
||||
MousePointerClick,
|
||||
BriefcaseBusiness,
|
||||
Settings,
|
||||
@@ -237,6 +238,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/fixed-questions")}
|
||||
>
|
||||
<MessageSquareText className="size-4" />
|
||||
问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/position-roles")}
|
||||
@@ -558,6 +567,12 @@ function LegacyAccordionSidebar({
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/fixed-questions")}>
|
||||
<MessageSquareText className="size-4" />
|
||||
问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
|
||||
<BriefcaseBusiness className="size-4" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./artifact-file-detail";
|
||||
export * from "./artifact-file-list";
|
||||
export * from "./artifact-file-detail";
|
||||
export * from "./artifact-directory";
|
||||
export * from "./artifact-file-list";
|
||||
export * from "./artifact-trigger";
|
||||
export * from "./context";
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -13,6 +14,10 @@ import {
|
||||
} from "@/components/ui/resizable";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import {
|
||||
listThreadArtifacts,
|
||||
mergeArtifactPaths,
|
||||
} from "@/core/artifacts/listing";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||
@@ -32,6 +37,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
ArtifactDirectory,
|
||||
ArtifactFileList,
|
||||
useArtifacts,
|
||||
} from "../artifacts";
|
||||
@@ -40,6 +46,7 @@ import { SkillDisplaySources } from "../skill-display-sources";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||
const DIRECTORY_OPEN_MODE = { chat: 48, artifacts: 52 };
|
||||
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
||||
|
||||
type BackendReferenceSource = Parameters<typeof SkillDisplaySources>[0]["sources"][number];
|
||||
@@ -78,7 +85,7 @@ const ChatBox: React.FC<{
|
||||
threadId,
|
||||
disableArtifacts = false,
|
||||
}) => {
|
||||
const { thread } = useThread();
|
||||
const { isMock, thread } = useThread();
|
||||
const pathname = usePathname();
|
||||
const threadIdRef = useRef(threadId);
|
||||
const artifactsCountRef = useRef(0);
|
||||
@@ -92,6 +99,8 @@ const ChatBox: React.FC<{
|
||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||
const [settings] = useLocalSettings();
|
||||
const artifactDirectoryModeEnabled =
|
||||
settings.display.artifactDirectoryModeEnabled ?? false;
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
// Frontend RAG modes write their own hidden source tool messages. They use
|
||||
@@ -110,6 +119,52 @@ const ChatBox: React.FC<{
|
||||
referencesEnabled && !hasFrontendRag;
|
||||
const { skills } = useSkills();
|
||||
|
||||
const canScanArtifactDirectory =
|
||||
artifactDirectoryModeEnabled &&
|
||||
!disableArtifacts &&
|
||||
!isMock &&
|
||||
!!threadId &&
|
||||
threadId !== "new";
|
||||
const artifactListingQuery = useQuery({
|
||||
queryKey: ["artifact-directory", threadId],
|
||||
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
|
||||
enabled: canScanArtifactDirectory,
|
||||
refetchInterval: thread.isLoading ? 1_500 : false,
|
||||
retry: false,
|
||||
});
|
||||
const checkpointArtifacts = useMemo(
|
||||
() => (disableArtifacts ? [] : (thread.values.artifacts ?? [])),
|
||||
[disableArtifacts, thread.values.artifacts],
|
||||
);
|
||||
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
|
||||
const availableArtifacts = useMemo(
|
||||
() =>
|
||||
artifactDirectoryModeEnabled
|
||||
? mergeArtifactPaths(
|
||||
checkpointArtifacts,
|
||||
(artifactListingQuery.data?.files ?? []).map((file) => file.path),
|
||||
)
|
||||
: checkpointArtifacts,
|
||||
[
|
||||
artifactDirectoryModeEnabled,
|
||||
artifactListingQuery.data?.files,
|
||||
checkpointArtifacts,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canScanArtifactDirectory || thread.isLoading) return;
|
||||
// Run once after streaming settles as well as when checkpoint artifacts
|
||||
// change, so files written without present_files are not missed by the
|
||||
// last polling tick.
|
||||
void artifactListingQuery.refetch();
|
||||
}, [
|
||||
artifactListingQuery.refetch,
|
||||
canScanArtifactDirectory,
|
||||
checkpointArtifactSignature,
|
||||
thread.isLoading,
|
||||
]);
|
||||
|
||||
const {
|
||||
artifacts,
|
||||
open: artifactsOpen,
|
||||
@@ -302,7 +357,8 @@ const ChatBox: React.FC<{
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (threadIdRef.current !== threadId) {
|
||||
const threadChanged = threadIdRef.current !== threadId;
|
||||
if (threadChanged) {
|
||||
threadIdRef.current = threadId;
|
||||
artifactsCountRef.current = 0;
|
||||
deselect();
|
||||
@@ -316,15 +372,21 @@ const ChatBox: React.FC<{
|
||||
}
|
||||
|
||||
// Update artifacts from the current thread
|
||||
const nextArtifacts = disableArtifacts ? [] : (thread.values.artifacts ?? []);
|
||||
const nextArtifacts = availableArtifacts;
|
||||
const hadArtifacts = artifactsCountRef.current > 0;
|
||||
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
|
||||
artifactsCountRef.current = nextArtifacts.length;
|
||||
setArtifacts(nextArtifacts);
|
||||
const artifactsChanged =
|
||||
artifacts.length !== nextArtifacts.length ||
|
||||
artifacts.some((artifact, index) => artifact !== nextArtifacts[index]);
|
||||
if (artifactsChanged) {
|
||||
setArtifacts(nextArtifacts);
|
||||
}
|
||||
if (
|
||||
!disableArtifacts &&
|
||||
(hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) &&
|
||||
!selectedArtifact
|
||||
nextArtifacts.length > 0 &&
|
||||
((artifactDirectoryModeEnabled && threadChanged) ||
|
||||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
|
||||
) {
|
||||
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
||||
setArtifactsOpen(true);
|
||||
@@ -351,15 +413,41 @@ const ChatBox: React.FC<{
|
||||
}
|
||||
}, [
|
||||
threadId,
|
||||
artifactDirectoryModeEnabled,
|
||||
autoSelectFirstArtifact,
|
||||
deselect,
|
||||
selectArtifact,
|
||||
selectedArtifact,
|
||||
artifacts,
|
||||
setArtifacts,
|
||||
thread.values.artifacts,
|
||||
availableArtifacts,
|
||||
disableArtifacts,
|
||||
]);
|
||||
|
||||
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
|
||||
useEffect(() => {
|
||||
const wasEnabled = directoryModeWasEnabledRef.current;
|
||||
directoryModeWasEnabledRef.current = artifactDirectoryModeEnabled;
|
||||
if (
|
||||
wasEnabled ||
|
||||
!artifactDirectoryModeEnabled ||
|
||||
availableArtifacts.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!selectedArtifact) {
|
||||
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!);
|
||||
}
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}, [
|
||||
artifactDirectoryModeEnabled,
|
||||
availableArtifacts,
|
||||
selectArtifact,
|
||||
selectedArtifact,
|
||||
setArtifactsOpen,
|
||||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
if (disableArtifacts) {
|
||||
return false;
|
||||
@@ -370,7 +458,10 @@ const ChatBox: React.FC<{
|
||||
return artifactsOpen;
|
||||
}, [artifactsOpen, artifacts, disableArtifacts]);
|
||||
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
|
||||
const showTabs = !!selectedArtifact && hasCitationReferences;
|
||||
const showTabs =
|
||||
hasCitationReferences &&
|
||||
(!!selectedArtifact ||
|
||||
(artifactDirectoryModeEnabled && artifacts.length > 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedArtifact) {
|
||||
@@ -390,12 +481,103 @@ const ChatBox: React.FC<{
|
||||
useEffect(() => {
|
||||
if (layoutRef.current) {
|
||||
if (sidePanelOpen) {
|
||||
layoutRef.current.setLayout(OPEN_MODE);
|
||||
layoutRef.current.setLayout(
|
||||
artifactDirectoryModeEnabled && artifactPanelOpen
|
||||
? DIRECTORY_OPEN_MODE
|
||||
: OPEN_MODE,
|
||||
);
|
||||
} else {
|
||||
layoutRef.current.setLayout(CLOSE_MODE);
|
||||
}
|
||||
}
|
||||
}, [sidePanelOpen]);
|
||||
}, [artifactDirectoryModeEnabled, artifactPanelOpen, sidePanelOpen]);
|
||||
|
||||
const artifactDocumentPanel = artifactDirectoryModeEnabled ? (
|
||||
<div className="flex size-full min-w-0 overflow-hidden rounded-lg border">
|
||||
<ArtifactDirectory
|
||||
artifacts={artifacts}
|
||||
selectedArtifact={selectedArtifact}
|
||||
onSelect={(artifact) => {
|
||||
selectArtifact(artifact);
|
||||
setArtifactsOpen(true);
|
||||
}}
|
||||
onRefresh={
|
||||
canScanArtifactDirectory
|
||||
? () => void artifactListingQuery.refetch()
|
||||
: undefined
|
||||
}
|
||||
refreshing={artifactListingQuery.isFetching}
|
||||
/>
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden">
|
||||
{selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
key={selectedArtifactKey ?? selectedArtifact}
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
allowVirtualMarkdownEdit
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => setArtifactsOpen(false)}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="请选择文件"
|
||||
description="从左侧目录中选择一个文件进行查看"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
key={selectedArtifactKey ?? selectedArtifact}
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
allowVirtualMarkdownEdit
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => setArtifactsOpen(false)}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{artifacts.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="No artifact selected"
|
||||
description="Select an artifact to view its details"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||
<header className="shrink-0">
|
||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
files={artifacts}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (disableArtifacts) {
|
||||
return (
|
||||
@@ -455,13 +637,7 @@ const ChatBox: React.FC<{
|
||||
</Button>
|
||||
</div>
|
||||
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
<ArtifactFileDetail
|
||||
key={selectedArtifactKey ?? selectedArtifact}
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
allowVirtualMarkdownEdit
|
||||
/>
|
||||
{artifactDocumentPanel}
|
||||
</TabsContent>
|
||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
||||
<ReferencePanel
|
||||
@@ -470,14 +646,8 @@ const ChatBox: React.FC<{
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
key={selectedArtifactKey ?? selectedArtifact}
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
allowVirtualMarkdownEdit
|
||||
/>
|
||||
) : artifactPanelOpen ? (
|
||||
artifactDocumentPanel
|
||||
) : hasCitationReferences ? (
|
||||
<div className="relative size-full overflow-hidden">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
@@ -497,41 +667,7 @@ const ChatBox: React.FC<{
|
||||
sources={referenceSources}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{thread.values.artifacts?.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="No artifact selected"
|
||||
description="Select an artifact to view its details"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||
<header className="shrink-0">
|
||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
files={thread.values.artifacts ?? []}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BarChart3Icon,
|
||||
BrainCircuitIcon,
|
||||
BookMarkedIcon,
|
||||
CheckIcon,
|
||||
ClipboardCheckIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
BarChart3Icon,
|
||||
BrainCircuitIcon,
|
||||
BookMarkedIcon,
|
||||
CheckIcon,
|
||||
ClipboardCheckIcon,
|
||||
FileTextIcon,
|
||||
FolderTreeIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
PaperclipIcon,
|
||||
@@ -446,12 +447,20 @@ export function InputBox({
|
||||
[onContextChange, context],
|
||||
);
|
||||
|
||||
const handleWritingModeToggle = useCallback(() => {
|
||||
const handleWritingModeToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
writing_mode: !context.writing_mode,
|
||||
});
|
||||
}, [context, onContextChange]);
|
||||
}, [context, onContextChange]);
|
||||
|
||||
const artifactDirectoryModeEnabled =
|
||||
localSettings.display.artifactDirectoryModeEnabled ?? false;
|
||||
const handleArtifactDirectoryModeToggle = useCallback(() => {
|
||||
setLocalSettings("display", {
|
||||
artifactDirectoryModeEnabled: !artifactDirectoryModeEnabled,
|
||||
});
|
||||
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
||||
|
||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||
const handleMemoryInjectionToggle = useCallback(() => {
|
||||
@@ -854,7 +863,7 @@ export function InputBox({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!frontendRagActive && <Tooltip
|
||||
{!frontendRagActive && <Tooltip
|
||||
content={
|
||||
context.writing_mode
|
||||
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
|
||||
@@ -878,9 +887,38 @@ export function InputBox({
|
||||
)}
|
||||
<FileTextIcon className="size-3" />
|
||||
<span className="text-xs font-normal">写作</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!frontendRagActive && <Tooltip
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!frontendRagActive && <Tooltip
|
||||
content={
|
||||
artifactDirectoryModeEnabled
|
||||
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
|
||||
: "开启文件模式:在右侧目录中浏览当前对话生成的全部文件"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
aria-pressed={artifactDirectoryModeEnabled}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
artifactDirectoryModeEnabled
|
||||
? "border border-sky-300 bg-sky-50 text-sky-700 hover:bg-sky-100 dark:border-sky-600 dark:bg-sky-950/45 dark:text-sky-200"
|
||||
: compact
|
||||
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleArtifactDirectoryModeToggle}
|
||||
>
|
||||
{artifactDirectoryModeEnabled && (
|
||||
<CheckIcon className="size-3 text-sky-700 dark:text-sky-200" />
|
||||
)}
|
||||
<FolderTreeIcon className="size-3" />
|
||||
<span className="text-xs font-normal">文件</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!frontendRagActive && <Tooltip
|
||||
content={
|
||||
memoryInjectionEnabled
|
||||
? "记忆注入已开启:对话前会注入历史记忆"
|
||||
|
||||
@@ -76,6 +76,7 @@ function SelectionContent({
|
||||
error,
|
||||
source,
|
||||
variant,
|
||||
readOnly,
|
||||
}: {
|
||||
rows: LlmWikiKnowledgeBase[];
|
||||
value: string[];
|
||||
@@ -86,6 +87,7 @@ function SelectionContent({
|
||||
error: unknown;
|
||||
source: SelectorSource;
|
||||
variant: SelectorVariant;
|
||||
readOnly: boolean;
|
||||
}) {
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
const filtered = useMemo(() => {
|
||||
@@ -97,6 +99,7 @@ function SelectionContent({
|
||||
}, [rows, search]);
|
||||
const groups = useMemo(() => groupRows(filtered), [filtered]);
|
||||
const toggle = (id: string) => {
|
||||
if (readOnly) return;
|
||||
onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]);
|
||||
};
|
||||
|
||||
@@ -168,6 +171,7 @@ function SelectionContent({
|
||||
<DropdownMenuCheckboxItem
|
||||
key={item.id}
|
||||
checked={selected.has(item.id)}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={() => toggle(item.id)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
className={cn(
|
||||
@@ -183,6 +187,7 @@ function SelectionContent({
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={selected.has(item.id)}
|
||||
disabled={readOnly}
|
||||
onClick={() => toggle(item.id)}
|
||||
className={cn(
|
||||
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
|
||||
@@ -205,15 +210,21 @@ function SelectionContent({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
|
||||
<span className="text-[11px] text-muted-foreground">已选择 {value.length} 个</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}>
|
||||
全选
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{readOnly ? `本对话已选 ${value.length} 个` : `已选择 ${value.length} 个`}
|
||||
</span>
|
||||
{readOnly ? (
|
||||
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}>
|
||||
全选
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -226,6 +237,7 @@ export function LlmWikiKnowledgeSelector({
|
||||
agentId,
|
||||
autoSelectAll = false,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
className,
|
||||
label = "知识空间",
|
||||
variant = "popover",
|
||||
@@ -236,6 +248,8 @@ export function LlmWikiKnowledgeSelector({
|
||||
agentId?: string | null;
|
||||
autoSelectAll?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Keep the selected knowledge spaces visible without allowing the thread scope to change. */
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
variant?: SelectorVariant;
|
||||
@@ -293,6 +307,7 @@ export function LlmWikiKnowledgeSelector({
|
||||
error={error}
|
||||
source={source}
|
||||
variant={variant}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -315,9 +330,14 @@ export function LlmWikiKnowledgeSelector({
|
||||
);
|
||||
}
|
||||
|
||||
const selectedName = value.length === 1
|
||||
? rows.find((item) => item.id === value[0])?.name
|
||||
: null;
|
||||
const selectedNames = value
|
||||
.map((id) => rows.find((item) => item.id === id)?.name)
|
||||
.filter((name): name is string => !!name);
|
||||
const selectedLabel = selectedNames.length > 0
|
||||
? selectedNames.join("、")
|
||||
: value.length > 0
|
||||
? `已选 ${value.length} 个知识空间`
|
||||
: label;
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
@@ -332,9 +352,10 @@ export function LlmWikiKnowledgeSelector({
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
title={value.length > 0 ? selectedLabel : undefined}
|
||||
>
|
||||
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <Database className="size-3.5" />}
|
||||
<span className="truncate text-xs font-normal">{selectedName || label}</span>
|
||||
<span className="truncate text-xs font-normal">{selectedLabel}</span>
|
||||
{value.length > 0 && (
|
||||
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
|
||||
{value.length}
|
||||
|
||||
@@ -341,9 +341,9 @@ function ToolCall({
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const displayMatch =
|
||||
result !== undefined && isDisplaySamplePayload(result)
|
||||
? null
|
||||
const displayMatch =
|
||||
result !== undefined && isDisplaySamplePayload(result)
|
||||
? null
|
||||
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
||||
null);
|
||||
const displayItems =
|
||||
@@ -352,18 +352,24 @@ function ToolCall({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}))
|
||||
: [];
|
||||
if (displayItems.length > 0) {
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
label={
|
||||
displayMatch?.display.mode === "citation" || !displayMatch
|
||||
? "检索参考来源"
|
||||
: `获取 ${displayItems.length} 条结果`
|
||||
}
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}))
|
||||
: [];
|
||||
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
|
||||
const wikiSearchLabel = name === "llmwiki_search"
|
||||
? wikiQuery
|
||||
? `Wiki 文档检索 "${wikiQuery}"`
|
||||
: "Wiki 文档检索"
|
||||
: null;
|
||||
if (displayItems.length > 0) {
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
label={
|
||||
wikiSearchLabel || (displayMatch?.display.mode === "citation" || !displayMatch
|
||||
? "检索参考来源"
|
||||
: `获取 ${displayItems.length} 条结果`)
|
||||
}
|
||||
icon={SearchIcon}
|
||||
>
|
||||
<DisplayResultsList results={displayItems} />
|
||||
@@ -387,7 +393,7 @@ function ToolCall({
|
||||
mode: "citation" as const,
|
||||
}));
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label="检索参考来源" icon={SearchIcon}>
|
||||
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
||||
<DisplayResultsList results={defaultItems} />
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ExternalLinkIcon, FileTextIcon } from "lucide-react";
|
||||
import { BookOpenTextIcon, ChevronRightIcon, ExternalLinkIcon, FileTextIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
} from "@/core/llmwiki/source-links";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
|
||||
/** Whether a source URL is an internal WeKnora preview pointer. */
|
||||
export function isWeKnoraSourcePreviewUrl(url?: string | null): boolean {
|
||||
@@ -71,8 +73,20 @@ export function SourceCard({
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||
const snippet = compactReferenceText(content).slice(0, 280);
|
||||
const paragraphs = segmentReferenceText(content);
|
||||
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
|
||||
const matchedHeading = isWikiSource
|
||||
? String(
|
||||
(source.raw?.matched_heading as string | undefined) ||
|
||||
(source.raw?.matched_sections as Array<{ heading?: string }> | undefined)?.find(
|
||||
(section) => section.heading,
|
||||
)?.heading ||
|
||||
"",
|
||||
).trim()
|
||||
: "";
|
||||
const summary = isWikiSource && typeof source.raw?.summary === "string"
|
||||
? source.raw.summary
|
||||
: "";
|
||||
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
|
||||
const score =
|
||||
typeof source.score === "number"
|
||||
? source.score <= 1
|
||||
@@ -81,13 +95,22 @@ export function SourceCard({
|
||||
: null;
|
||||
const meta = [
|
||||
source.source,
|
||||
isWikiSource ? "检索对象:Wiki 文档" : null,
|
||||
matchedHeading ? `命中章节:${matchedHeading}` : null,
|
||||
source.author ? `作者 ${source.author}` : null,
|
||||
source.type && source.type !== "result" ? source.type : null,
|
||||
!isWikiSource && source.type && source.type !== "result" ? source.type : null,
|
||||
source.time,
|
||||
score ? `相关度 ${score}` : null,
|
||||
].filter(Boolean);
|
||||
const isCitation = source.mode === "citation";
|
||||
const showReferenceNumber = isCitation;
|
||||
const openSource = () => {
|
||||
if (previewTarget) {
|
||||
setDetailOpen(true);
|
||||
} else {
|
||||
setRelatedOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -102,11 +125,11 @@ export function SourceCard({
|
||||
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
|
||||
"cursor-pointer",
|
||||
)}
|
||||
onClick={() => setRelatedOpen(true)}
|
||||
onClick={openSource}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setRelatedOpen(true);
|
||||
openSource();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -136,11 +159,16 @@ export function SourceCard({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{source.url && (
|
||||
{(source.url || isWikiSource) && (
|
||||
previewTarget ? (
|
||||
<span className="text-primary bg-primary/10 mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium">
|
||||
<FileTextIcon className="size-3.5" />
|
||||
查看原文
|
||||
<span className={cn(
|
||||
"mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium",
|
||||
isWikiSource
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
|
||||
: "bg-primary/10 text-primary",
|
||||
)}>
|
||||
{isWikiSource ? <BookOpenTextIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />}
|
||||
{isWikiSource ? "Wiki 文档" : "查看原文"}
|
||||
</span>
|
||||
) : (
|
||||
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
||||
@@ -148,19 +176,9 @@ export function SourceCard({
|
||||
)}
|
||||
</div>
|
||||
{previewTarget && (
|
||||
<div className="mt-3 flex justify-start border-t pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-primary h-7 px-1.5 text-xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon className="mr-1.5 size-3.5" />
|
||||
查看详情
|
||||
</Button>
|
||||
<div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground">
|
||||
<span>{isWikiSource ? "点击卡片查看完整 Wiki" : "点击卡片查看详情"}</span>
|
||||
<ChevronRightIcon className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -173,7 +191,7 @@ export function SourceCard({
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
)}
|
||||
<Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||
{!previewTarget && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
@@ -210,13 +228,23 @@ export function SourceCard({
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{paragraphs.length > 0 ? (
|
||||
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p key={index} className="whitespace-pre-wrap">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
{content ? (
|
||||
<div className="max-h-[62vh] overflow-y-auto rounded-xl border bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
|
||||
{isWikiSource && (
|
||||
<div className="mb-4 flex items-center gap-2 border-b pb-3 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
<BookOpenTextIcon className="size-4" />
|
||||
本次检索命中的 Wiki 文档内容
|
||||
</div>
|
||||
)}
|
||||
<article className="mx-auto max-w-3xl rounded-lg bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={content}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
) : null}
|
||||
{source.url && !previewTarget && (
|
||||
@@ -225,7 +253,7 @@ export function SourceCard({
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -254,44 +282,18 @@ function compactReferenceText(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function segmentReferenceText(text: string): string[] {
|
||||
const explicit = text
|
||||
.split(/\n{2,}/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const base = explicit.length > 1 ? explicit : splitLongParagraph(text);
|
||||
return base.slice(0, 80);
|
||||
}
|
||||
|
||||
function splitLongParagraph(text: string): string[] {
|
||||
const normalized = text
|
||||
.split(/\n+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (normalized.length <= 360) {
|
||||
return normalized ? [normalized] : [];
|
||||
}
|
||||
|
||||
const sentences = normalized
|
||||
.split(/(?<=[。!?!?;;])\s*/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
if (sentences.length <= 1) {
|
||||
return normalized.match(/[\s\S]{1,360}/g) ?? [];
|
||||
}
|
||||
|
||||
const paragraphs: string[] = [];
|
||||
let current = "";
|
||||
for (const sentence of sentences) {
|
||||
if ((current + sentence).length > 360 && current) {
|
||||
paragraphs.push(current.trim());
|
||||
current = "";
|
||||
}
|
||||
current += sentence;
|
||||
}
|
||||
if (current.trim()) {
|
||||
paragraphs.push(current.trim());
|
||||
}
|
||||
return paragraphs;
|
||||
function plainTextReferencePreview(text: string): string {
|
||||
return compactReferenceText(
|
||||
text
|
||||
.replace(/^---[\s\S]*?---\s*/m, "")
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
|
||||
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/^\s*[-*+]\s+/gm, "")
|
||||
.replace(/^\s*\d+[.)]\s+/gm, "")
|
||||
.replace(/[>*_`~|]/g, " "),
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
HistoryIcon,
|
||||
ListTreeIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
MessageSquareTextIcon,
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
@@ -288,6 +289,16 @@ export function WorkspaceNavMenu() {
|
||||
推荐问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/admin/fixed-questions")
|
||||
}
|
||||
>
|
||||
<MessageSquareTextIcon />
|
||||
问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && logSystem?.enabled && logSystem.url && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -49,6 +49,17 @@ function normalizeIdentityPart(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function identityFromLoginName(value: unknown): string | null {
|
||||
const normalized = normalizeIdentityPart(value);
|
||||
if (!normalized) return null;
|
||||
// Explicit email login is domain-specific. Bare username login is the
|
||||
// stable business principal even if the backend's synthetic email domain or
|
||||
// internal UUID changes.
|
||||
return normalized.includes("@")
|
||||
? `email:${normalized}`
|
||||
: `account:${normalized}`;
|
||||
}
|
||||
|
||||
function readUserInfoIdentity(): string | null {
|
||||
let info: Record<string, unknown> | null = null;
|
||||
try {
|
||||
@@ -81,20 +92,24 @@ function readUserInfoIdentity(): string | null {
|
||||
/**
|
||||
* A stable cross-tab identity key for "did the account really change?" checks.
|
||||
*
|
||||
* Some external login paths can re-issue a backend ``user_id`` while resolving
|
||||
* to the same business account. Prefer business-facing identifiers
|
||||
* (email/username/userInfo) and keep ``user_id`` only as the final fallback.
|
||||
* Some external login paths can re-issue a backend ``user_id`` or use a
|
||||
* different synthetic email domain while resolving to the same business
|
||||
* account. Prefer the raw login username returned by the backend, then the
|
||||
* email local-part, and keep ``user_id`` only as the final fallback.
|
||||
*/
|
||||
export function getAccountIdentityKey(
|
||||
auth: LoginByUsernameResponse | null = getStoredAuth(),
|
||||
): string | null {
|
||||
if (!auth) return null;
|
||||
|
||||
const email = normalizeIdentityPart(auth.email);
|
||||
if (email) return `email:${email}`;
|
||||
const loginIdentity = identityFromLoginName(auth.username);
|
||||
if (loginIdentity) return loginIdentity;
|
||||
|
||||
const username = normalizeIdentityPart(auth.username);
|
||||
if (username) return `username:${username}`;
|
||||
const email = normalizeIdentityPart(auth.email);
|
||||
if (email) {
|
||||
const localPart = email.split("@", 1)[0];
|
||||
return localPart ? `account:${localPart}` : `email:${email}`;
|
||||
}
|
||||
|
||||
const userInfoIdentity = readUserInfoIdentity();
|
||||
if (userInfoIdentity) return `userInfo:${userInfoIdentity}`;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAccountIdentityKey } from "@/core/auth";
|
||||
import {
|
||||
AUTH_STATE_CHANGED_EVENT,
|
||||
getAccountDisplayName,
|
||||
getAccountIdentityKey,
|
||||
} from "@/core/auth";
|
||||
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
|
||||
|
||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||
@@ -14,14 +20,37 @@ function readIdentityKey(): string | null {
|
||||
return getAccountIdentityKey();
|
||||
}
|
||||
|
||||
function freshRouteForAccount(pathname: string): string {
|
||||
// A thread URL belongs to the old account. Keep its workspace/agent context
|
||||
// but move to a valid new conversation before the user can submit into the
|
||||
// stale thread. Other pages can stay in place and are remounted below.
|
||||
const chatMatch = pathname.match(/^(.*\/chats)\/[^/]+\/?$/);
|
||||
return chatMatch ? `${chatMatch[1]}/new` : pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount once at the app root. Watches localStorage auth changes from other
|
||||
* tabs and lets the user decide when to refresh on account switches.
|
||||
* Cross-tab auth boundary for normal pages.
|
||||
*
|
||||
* Same-account token re-issuance is ignored. A genuine account change clears
|
||||
* account-scoped query state, remounts the page, and moves an open chat to its
|
||||
* fresh-chat route without a full browser reload. Embedded/isolated tabs keep
|
||||
* their sessionStorage identity and never participate in this synchronization.
|
||||
*/
|
||||
export function AuthSyncWatcher() {
|
||||
export function AuthSyncWatcher({
|
||||
children,
|
||||
disabled = false,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const lastIdentityRef = useRef<string | null>(readIdentityKey());
|
||||
const [revision, setRevision] = useState(0);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
// 嵌入(iframe)态 / 独立会话态(?isolate=1):鉴权走按标签页的 sessionStorage,本就不与其它标签页
|
||||
// 共享,且不应被其它标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。
|
||||
if (isEmbedActive() || isIsolatedSession()) return;
|
||||
@@ -36,18 +65,27 @@ export function AuthSyncWatcher() {
|
||||
|
||||
lastIdentityRef.current = nextIdentity;
|
||||
|
||||
void queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
window.dispatchEvent(new Event(AUTH_STATE_CHANGED_EVENT));
|
||||
setRevision((value) => value + 1);
|
||||
|
||||
if (nextIdentity === null) {
|
||||
toast.info("账号已在其它页面退出", {
|
||||
id: IDENTITY_TOAST_ID,
|
||||
description: "当前页面已同步退出状态。",
|
||||
});
|
||||
navigate("/login", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.warning("检测到账号在其它标签页发生变化", {
|
||||
const freshRoute = freshRouteForAccount(location.pathname);
|
||||
if (freshRoute !== location.pathname) {
|
||||
navigate(freshRoute, { replace: true });
|
||||
}
|
||||
toast.info("账号已在页面间自动同步", {
|
||||
id: IDENTITY_TOAST_ID,
|
||||
description: "当前标签页仍是原会话。如需与新账号同步,请刷新页面。",
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: "刷新同步",
|
||||
onClick: () => window.location.reload(),
|
||||
},
|
||||
description: `当前账号:${getAccountDisplayName()}。无需刷新页面。`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,14 +106,24 @@ export function AuthSyncWatcher() {
|
||||
settleTimer = setTimeout(evaluateSettledIdentity, SETTLE_DELAY_MS);
|
||||
}
|
||||
|
||||
function handleSameTabAuthChange() {
|
||||
// `storage` never fires in the tab that called setStoredAuth/clearStoredAuth.
|
||||
// Keep the comparison baseline current so a later same-account token
|
||||
// renewal from another normal page is not compared with the pre-login
|
||||
// `null` identity and falsely classified as an account switch.
|
||||
lastIdentityRef.current = readIdentityKey();
|
||||
}
|
||||
|
||||
window.addEventListener("storage", handleStorage);
|
||||
window.addEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange);
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [disabled, location.pathname, navigate, queryClient]);
|
||||
|
||||
return null;
|
||||
return children ? <Fragment key={revision}>{children}</Fragment> : null;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
getAccountIdentityKey,
|
||||
getStoredAuth,
|
||||
} from "@/core/auth";
|
||||
import { isEmbedActive } from "@/core/embed/embed-session";
|
||||
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
|
||||
|
||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||
|
||||
@@ -65,7 +65,9 @@ export function useLiveAccount(): LiveAccount {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
if (!isEmbedActive()) {
|
||||
// Normal pages share localStorage identity across tabs. Embedded and
|
||||
// isolated pages deliberately keep their per-tab sessionStorage identity.
|
||||
if (!isEmbedActive() && !isIsolatedSession()) {
|
||||
window.addEventListener("storage", onStorage);
|
||||
}
|
||||
window.addEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
|
||||
|
||||
@@ -456,7 +456,7 @@ export const enUS: Translations = {
|
||||
lightDescription: "Bright palette with higher contrast for daytime.",
|
||||
darkDescription: "Dim palette that reduces glare for focus.",
|
||||
lightblueDescription: "A crisp light palette built around blue.",
|
||||
lightgreenDescription: "A crisp light palette built around WeKnora green.",
|
||||
lightgreenDescription: "A crisp light palette built around fresh green tones.",
|
||||
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
|
||||
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
|
||||
languageTitle: "Language",
|
||||
|
||||
@@ -437,7 +437,7 @@ export const zhCN: Translations = {
|
||||
lightDescription: "更明亮的配色,适合日间使用。",
|
||||
darkDescription: "更暗的配色,减少眩光方便专注。",
|
||||
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
||||
lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
|
||||
lightgreenDescription: "采用清新绿色的浅色配色。",
|
||||
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
|
||||
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
|
||||
languageTitle: "语言",
|
||||
|
||||
@@ -44,7 +44,7 @@ export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget
|
||||
params.get("wikiSlug")?.trim() ||
|
||||
params.get("slug")?.trim() ||
|
||||
"";
|
||||
return knowledgeBaseId && chunkId
|
||||
return knowledgeBaseId && (wikiSlug || chunkId || documentId)
|
||||
? {
|
||||
knowledgeBaseId,
|
||||
chunkId,
|
||||
@@ -75,6 +75,7 @@ export function resolveWeKnoraSourceTarget(source: KnowledgeSourceLike): WeKnora
|
||||
fromPreview?.documentId ||
|
||||
"";
|
||||
const chunkId = source.chunk_id || source.chunkId || fromPreview?.chunkId || "";
|
||||
if (!wikiSlug && !chunkId && !documentId) return null;
|
||||
return {
|
||||
knowledgeBaseId,
|
||||
chunkId: chunkId || undefined,
|
||||
@@ -94,6 +95,43 @@ export function buildWeKnoraSourceIframeUrl(target: WeKnoraSourceTarget): string
|
||||
return `/#/page/workspace/knowledge/${encodeURIComponent(target.knowledgeBaseId)}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight native reader for one Wiki page. Unlike the knowledge-base
|
||||
* detail route this does not create a WeKnora iframe session.
|
||||
*/
|
||||
export function buildWeKnoraWikiPageUrl(
|
||||
knowledgeBaseId: string,
|
||||
wikiSlug: string,
|
||||
): string {
|
||||
return `/#${buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)}`;
|
||||
}
|
||||
|
||||
export function buildWeKnoraWikiPagePath(
|
||||
knowledgeBaseId: string,
|
||||
wikiSlug: string,
|
||||
): string {
|
||||
const params = new URLSearchParams({ wikiSlug });
|
||||
return `/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view?${params.toString()}`;
|
||||
}
|
||||
|
||||
/** Public, chrome-free Wiki reader intended for third-party iframe embedding. */
|
||||
export function buildPublicKnowledgeWikiPath(
|
||||
knowledgeBaseId: string,
|
||||
wikiSlug?: string,
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
if (wikiSlug) params.set("wikiSlug", wikiSlug);
|
||||
const query = params.toString();
|
||||
return `/embed/knowledge/${encodeURIComponent(knowledgeBaseId)}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
export function buildPublicKnowledgeWikiUrl(
|
||||
knowledgeBaseId: string,
|
||||
wikiSlug?: string,
|
||||
): string {
|
||||
return `/#${buildPublicKnowledgeWikiPath(knowledgeBaseId, wikiSlug)}`;
|
||||
}
|
||||
|
||||
export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): string {
|
||||
const recUuid = source.recUuid || source.rec_uuid || source.url || "";
|
||||
if (!recUuid) return "";
|
||||
@@ -108,7 +146,9 @@ export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): stri
|
||||
export function openKnowledgeSource(source: KnowledgeSourceLike): boolean {
|
||||
const weknoraTarget = resolveWeKnoraSourceTarget(source);
|
||||
const href = weknoraTarget
|
||||
? buildWeKnoraSourceIframeUrl(weknoraTarget)
|
||||
? weknoraTarget.wikiSlug
|
||||
? buildWeKnoraWikiPageUrl(weknoraTarget.knowledgeBaseId, weknoraTarget.wikiSlug)
|
||||
: buildWeKnoraSourceIframeUrl(weknoraTarget)
|
||||
: buildLegacyKnowledgeSourceUrl(source);
|
||||
if (!href) return false;
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { buildWeKnoraWikiPageUrl } from "@/core/llmwiki/source-links";
|
||||
|
||||
export type WikiPageUrlBuilder = (
|
||||
knowledgeBaseId: string,
|
||||
wikiSlug: string,
|
||||
) => string;
|
||||
|
||||
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,
|
||||
buildPageUrl: WikiPageUrlBuilder = buildWeKnoraWikiPageUrl,
|
||||
) {
|
||||
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}](${buildPageUrl(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,
|
||||
buildPageUrl?: WikiPageUrlBuilder,
|
||||
) {
|
||||
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,
|
||||
buildPageUrl,
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
enabled: true,
|
||||
},
|
||||
display: {
|
||||
streamSpeedMeterEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
contextCompressionEnabled: false,
|
||||
display: {
|
||||
streamSpeedMeterEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
artifactDirectoryModeEnabled: false,
|
||||
contextCompressionEnabled: false,
|
||||
citationCleanupWords: [],
|
||||
fontScale: 1,
|
||||
},
|
||||
@@ -43,7 +44,12 @@ export interface LocalSettings {
|
||||
* Global switch for skill result display adapters. When disabled, chat
|
||||
* messages render tool results with the legacy generic search UI only.
|
||||
*/
|
||||
ragModeEnabled: boolean;
|
||||
ragModeEnabled: boolean;
|
||||
/**
|
||||
* When enabled, the artifact panel scans every generated output and keeps
|
||||
* a directory tree visible beside the selected file preview.
|
||||
*/
|
||||
artifactDirectoryModeEnabled: boolean;
|
||||
/**
|
||||
* Per-user switch for context compression (上下文压缩). When enabled, long
|
||||
* conversations are summarized transiently before the model call to save
|
||||
|
||||
@@ -761,17 +761,15 @@ export function useThreadStream({
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A "thread not found" 404 almost always means the URL points at a
|
||||
// thread from a previous login session — either this tab outlived a
|
||||
// logout/re-login or the thread genuinely belongs to a different user.
|
||||
// Re-frame the error around the *current* logged-in identity and bounce
|
||||
// the user to a fresh chat instead of leaving them stuck on the stale
|
||||
// route looking at a raw HTTP 404.
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
const userLabel = buildCurrentUserLabel();
|
||||
toast.info(
|
||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,正在为你刷新页面…`,
|
||||
);
|
||||
// A "thread not found" 404 means this route cannot accept another turn.
|
||||
// Move to a fresh chat without a full browser reload; the old reload path
|
||||
// made every click look like a crash and could loop indefinitely when a
|
||||
// different tab's cookie kept overriding this tab's Bearer token.
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
const userLabel = buildCurrentUserLabel();
|
||||
toast.info(
|
||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
|
||||
);
|
||||
// Prefer staying inside an agent chat context when the user is on an
|
||||
// agent thread URL — otherwise fall back to the global new chat.
|
||||
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
||||
@@ -782,18 +780,7 @@ export function useThreadStream({
|
||||
: iframeMatch
|
||||
? "/page/workspace/chats_iframe/new"
|
||||
: "/page/workspace/chats/new";
|
||||
// First navigate to a safe URL so the post-reload page lands on a
|
||||
// valid route. Then full-reload so TanStack Query cache, context
|
||||
// providers and any other state captured under the previous user's
|
||||
// identity get rebuilt against the current login.
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.hash = target;
|
||||
window.setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 900);
|
||||
} else {
|
||||
navigate(target, { replace: true });
|
||||
}
|
||||
navigate(target, { replace: true });
|
||||
return;
|
||||
}
|
||||
// 上一轮还没结束又发了新问题(409)或并发达到上限(429):
|
||||
|
||||
@@ -13,8 +13,9 @@ import { getStoredAuth } from "@/core/auth";
|
||||
* 404 just leaves the user staring at a scary error. Both the chat stream
|
||||
* (``core/threads/hooks.ts``) and the AI-writing stream
|
||||
* (``open-canvas/contexts/AIWritingContext.tsx``) funnel their stream errors
|
||||
* through here so the UX is identical: detect the login-change 404 and prompt
|
||||
* the user to refresh against the current identity instead of erroring out.
|
||||
* through here so the UX is identical: detect the login-change 404 and reset
|
||||
* the stale in-memory session against the current identity instead of showing
|
||||
* a raw error or reloading the browser page.
|
||||
*/
|
||||
|
||||
/** Normalize an unknown stream error into a human-readable message string. */
|
||||
@@ -108,22 +109,15 @@ export function buildCurrentUserLabel(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-frame a login-change 404 around the current identity and reload the page.
|
||||
* Re-frame a login-change 404 around the current identity and reset its view.
|
||||
*
|
||||
* Shows a friendly toast naming the current login, then full-reloads after a
|
||||
* short delay so the toast is visible and so TanStack Query cache / context
|
||||
* providers / any state captured under the previous user get rebuilt against
|
||||
* the current login. Used by streams whose session id lives in component state
|
||||
* (not the URL) — a plain reload lands them back on a fresh, valid page.
|
||||
* Streams whose session id lives in component state pass a local reset callback
|
||||
* so they can discard the inaccessible session without a browser reload.
|
||||
*/
|
||||
export function notifyThreadAccessChangedAndReload(): void {
|
||||
export function notifyThreadAccessChanged(onReset: () => void): void {
|
||||
const userLabel = buildCurrentUserLabel();
|
||||
toast.info(
|
||||
`当前登录用户为 ${userLabel},原会话不存在或无权访问,正在为你刷新页面…`,
|
||||
`当前登录用户为 ${userLabel},原会话不存在或无权访问,已为你重置为新会话。`,
|
||||
);
|
||||
if (typeof window !== "undefined") {
|
||||
window.setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 900);
|
||||
}
|
||||
onReset();
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { getAPIClient } from '@/core/api';
|
||||
import {
|
||||
getStreamErrorMessage,
|
||||
isThreadNotAccessibleError,
|
||||
notifyThreadAccessChangedAndReload,
|
||||
notifyThreadAccessChanged,
|
||||
} from '@/core/threads/thread-access-error';
|
||||
|
||||
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
|
||||
@@ -299,10 +299,13 @@ const AIWritingStreamHost = memo(function AIWritingStreamHost({
|
||||
// thread belongs to a previous login session — the user switched accounts
|
||||
// (or this tab outlived a re-login), so the current account legitimately
|
||||
// can't access the old thread. Instead of dumping the raw HTTP 404 into
|
||||
// the panel, re-frame it around the current identity and reload — same UX
|
||||
// the chat stream gives (see core/threads/hooks.ts).
|
||||
// the panel, re-frame it around the current identity and discard the
|
||||
// stale component-state session without reloading the browser page.
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
notifyThreadAccessChangedAndReload();
|
||||
notifyThreadAccessChanged(() => {
|
||||
continueTargetRef.current = null;
|
||||
dispatchRef.current?.({ type: 'reset' });
|
||||
});
|
||||
return;
|
||||
}
|
||||
dispatchRef.current?.({ type: 'set_error', message });
|
||||
@@ -410,6 +413,13 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
// (刷新会断开 SSE,后端默认 on_disconnect=cancel 取消该 run,不留孤儿)。
|
||||
const threadId = state.sessionId ?? undefined;
|
||||
|
||||
const resetAfterThreadAccessError = useCallback(() => {
|
||||
setArtifact(null);
|
||||
seenInterruptRef.current = null;
|
||||
continueTargetRef.current = null;
|
||||
dispatch({ type: 'reset' });
|
||||
}, [setArtifact]);
|
||||
|
||||
useEffect(() => {
|
||||
const s = state.status;
|
||||
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
|
||||
@@ -473,12 +483,12 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
} catch (err) {
|
||||
const message = getStreamErrorMessage(err);
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
notifyThreadAccessChangedAndReload();
|
||||
notifyThreadAccessChanged(resetAfterThreadAccessError);
|
||||
return;
|
||||
}
|
||||
dispatch({ type: 'set_error', message });
|
||||
}
|
||||
}, [state.status]);
|
||||
}, [state.status, resetAfterThreadAccessError]);
|
||||
|
||||
const submitIntervention = useCallback(async (payload: UserInterventionPayload) => {
|
||||
if (!state.sessionId || !state.currentPause) return;
|
||||
@@ -585,14 +595,14 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
} catch (err) {
|
||||
const message = getStreamErrorMessage(err);
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
notifyThreadAccessChangedAndReload();
|
||||
notifyThreadAccessChanged(resetAfterThreadAccessError);
|
||||
return;
|
||||
}
|
||||
dispatch({ type: 'set_error', message });
|
||||
}
|
||||
}, [state.sessionId, state.currentPause, state.materials, state.outline,
|
||||
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
|
||||
state.status]);
|
||||
state.status, resetAfterThreadAccessError]);
|
||||
|
||||
const pauseWriting = useCallback(() => {
|
||||
// 真停:取消当前 run(graph 已完成的阶段已落 checkpoint)。继续时从该 checkpoint 续跑。
|
||||
@@ -626,13 +636,13 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
} catch (err) {
|
||||
const message = getStreamErrorMessage(err);
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
notifyThreadAccessChangedAndReload();
|
||||
notifyThreadAccessChanged(resetAfterThreadAccessError);
|
||||
return;
|
||||
}
|
||||
dispatch({ type: 'set_error', message });
|
||||
}
|
||||
})();
|
||||
}, [state.request]);
|
||||
}, [state.request, resetAfterThreadAccessError]);
|
||||
|
||||
// Restore an unfinished history session: make it active, load its checkpoint, then let
|
||||
// the host skip completed awaiting_* stages or re-run the current unfinished stage.
|
||||
|
||||
@@ -776,6 +776,17 @@ export default function AgentChatPage({
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
knowledgeSelector={
|
||||
<LlmWikiKnowledgeSelector
|
||||
source="agent"
|
||||
agentId={agent_id}
|
||||
value={selectedKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
readOnly
|
||||
label="知识空间"
|
||||
/>
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
|
||||
@@ -838,20 +838,19 @@ export default function ChatPage() {
|
||||
: undefined
|
||||
}
|
||||
knowledgeSelector={
|
||||
isLandingState ? (
|
||||
<LlmWikiKnowledgeSelector
|
||||
source={selectedAgentId ? "agent" : "all"}
|
||||
agentId={selectedAgentId}
|
||||
value={selectedKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
autoSelectAll={!!selectedAgentId}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
knowledgeSelectionLoading
|
||||
}
|
||||
label="知识空间"
|
||||
/>
|
||||
) : undefined
|
||||
<LlmWikiKnowledgeSelector
|
||||
source={selectedAgentId ? "agent" : "all"}
|
||||
agentId={selectedAgentId}
|
||||
value={selectedKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
autoSelectAll={isLandingState && !!selectedAgentId}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
knowledgeSelectionLoading
|
||||
}
|
||||
readOnly={!isLandingState}
|
||||
label="知识空间"
|
||||
/>
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
|
||||
@@ -37,11 +37,11 @@ import { cn } from "@/lib/utils";
|
||||
import { openKnowledgeSource } from "@/core/llmwiki/source-links";
|
||||
import MarkdownRenderer from "@/strategy-components/MarkdownRenderer";
|
||||
import {
|
||||
type LlmWikiSearchResult,
|
||||
useLlmWikiKnowledgeBases,
|
||||
useLlmWikiRuntime,
|
||||
} from "@/strategy-components/api/llmwiki";
|
||||
import {
|
||||
type EnterpriseResearchEvidence,
|
||||
type EnterpriseResearchTask,
|
||||
type EnterpriseResearchTaskStatus,
|
||||
type EnterpriseResearchReportEvent,
|
||||
@@ -636,7 +636,7 @@ function EvidencePane({
|
||||
onStartReport, onCancelReport, onRestoreReport, onSourceSelect,
|
||||
}: {
|
||||
task: EnterpriseResearchTask | null;
|
||||
source: LlmWikiSearchResult | null;
|
||||
source: EnterpriseResearchEvidence | null;
|
||||
reportJob: EnterpriseResearchReportJob | null;
|
||||
reportJobs: EnterpriseResearchReportJob[];
|
||||
reportDraft: string;
|
||||
@@ -665,7 +665,7 @@ function EvidencePane({
|
||||
const latestRewrite = reportJobs.find((job) => job.action === "rewrite" && job.status === "completed" && job.original_report);
|
||||
const latestCommittedJob = reportJobs.find((job) => job.action !== "followup" && job.status === "completed");
|
||||
const completedFollowups = reportJobs.filter((job) => job.action === "followup" && job.status === "completed");
|
||||
const selectedSources = task.selected_source_ids.map((id) => task.sources.find((item) => item.chunk_id === id)).filter(Boolean) as LlmWikiSearchResult[];
|
||||
const selectedSources = task.selected_source_ids.map((id) => task.sources.find((item) => item.chunk_id === id)).filter(Boolean) as EnterpriseResearchEvidence[];
|
||||
const citationIndex = new Map(selectedSources.map((item, index) => [item.chunk_id, index + 1]));
|
||||
const renderedReport = currentReport.replace(/\[资料:([^\]]+)\]/g, (match, id: string) => {
|
||||
const index = citationIndex.get(id.trim());
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import {
|
||||
Info,
|
||||
MessageSquareText,
|
||||
Plus,
|
||||
Save,
|
||||
Search,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
type FixedQuestion,
|
||||
useFixedQuestionActions,
|
||||
useFixedQuestions,
|
||||
} from "@/strategy-components/api/fixed-questions";
|
||||
|
||||
let sequence = 1;
|
||||
|
||||
function newId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `fixed-${Date.now()}-${sequence++}`;
|
||||
}
|
||||
|
||||
function serialize(rows: FixedQuestion[]): string {
|
||||
return JSON.stringify(
|
||||
rows
|
||||
.map(({ id, question, answer, enabled, tokensPerSecond, sortOrder }) => ({
|
||||
id,
|
||||
question,
|
||||
answer,
|
||||
enabled,
|
||||
tokensPerSecond,
|
||||
sortOrder,
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
);
|
||||
}
|
||||
|
||||
export default function FixedQuestionsPage() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const { data: serverQuestions = [], isLoading } = useFixedQuestions();
|
||||
const { save } = useFixedQuestionActions();
|
||||
const [rows, setRows] = useState<FixedQuestion[]>([]);
|
||||
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const sourceKey = useMemo(
|
||||
() => serialize(serverQuestions),
|
||||
[serverQuestions],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (loadedKey !== sourceKey) {
|
||||
setRows(serverQuestions);
|
||||
setLoadedKey(sourceKey);
|
||||
}
|
||||
}, [loadedKey, serverQuestions, sourceKey]);
|
||||
|
||||
const dirty = useMemo(() => serialize(rows) !== sourceKey, [rows, sourceKey]);
|
||||
const search = query.trim().toLowerCase();
|
||||
const visibleRows = useMemo(
|
||||
() =>
|
||||
search
|
||||
? rows.filter(
|
||||
(row) =>
|
||||
row.question.toLowerCase().includes(search) ||
|
||||
row.answer.toLowerCase().includes(search),
|
||||
)
|
||||
: rows,
|
||||
[rows, search],
|
||||
);
|
||||
const enabledCount = rows.filter(
|
||||
(row) => row.enabled && row.question.trim() && row.answer.trim(),
|
||||
).length;
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/page/workspace/chats/new" replace />;
|
||||
}
|
||||
|
||||
const patchRow = (id: string, patch: Partial<FixedQuestion>) => {
|
||||
setRows((current) =>
|
||||
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||||
);
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
setRows((current) => [
|
||||
{
|
||||
id: newId(),
|
||||
question: "",
|
||||
answer: "",
|
||||
enabled: true,
|
||||
tokensPerSecond: 100,
|
||||
sortOrder: current.length,
|
||||
},
|
||||
...current,
|
||||
]);
|
||||
};
|
||||
|
||||
const removeRow = (id: string) => {
|
||||
setRows((current) => current.filter((row) => row.id !== id));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const cleaned = rows.map((row, index) => ({
|
||||
...row,
|
||||
question: row.question.trim(),
|
||||
tokensPerSecond: Math.round(row.tokensPerSecond),
|
||||
sortOrder: index,
|
||||
}));
|
||||
for (const row of cleaned) {
|
||||
if (!row.question) {
|
||||
toast.error("问题不能为空");
|
||||
return;
|
||||
}
|
||||
if (!row.answer.trim()) {
|
||||
toast.error(`问题「${row.question}」的回答不能为空`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(row.tokensPerSecond) ||
|
||||
row.tokensPerSecond < 1 ||
|
||||
row.tokensPerSecond > 1000
|
||||
) {
|
||||
toast.error(`问题「${row.question}」的流式速度须为 1–1000 token/s`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const row of cleaned) {
|
||||
if (seen.has(row.question)) {
|
||||
toast.error(`问题「${row.question}」重复,请删除或修改重复项`);
|
||||
return;
|
||||
}
|
||||
seen.add(row.question);
|
||||
}
|
||||
try {
|
||||
await save.mutateAsync(cleaned);
|
||||
toast.success("问题配置已保存并生效");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="size-full overflow-auto bg-muted/20 px-4 py-6 sm:px-6">
|
||||
<div className="mx-auto max-w-5xl space-y-5">
|
||||
<section className="rounded-2xl border bg-background p-5 shadow-sm">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-xl bg-primary/10 p-2.5 text-primary">
|
||||
<MessageSquareText className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-xl font-semibold">问题配置</h1>
|
||||
<span className="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground">
|
||||
{rows.length} 条 / {enabledCount} 条启用
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
为完全相同的问题配置固定回答,命中时直接流式返回,不调用大模型。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={addRow}>
|
||||
<Plus className="size-4" />
|
||||
新增问题
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || save.isPending}
|
||||
>
|
||||
<Save className="size-4" />
|
||||
{save.isPending ? "保存中…" : "保存配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3.5 py-3 text-sm text-amber-900">
|
||||
<Info className="mt-0.5 size-4 shrink-0" />
|
||||
<span>
|
||||
匹配区分大小写、标点和空格;停用的配置会保留但不参与匹配。带附件或图片的提问不会使用固定回答。
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3 rounded-2xl border bg-background p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索问题或回答…"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Zap className="size-3.5 text-amber-500" />
|
||||
启用项保存后立即生效
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-2xl border bg-background py-16 text-center text-sm text-muted-foreground">
|
||||
加载中…
|
||||
</div>
|
||||
) : visibleRows.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed bg-background py-16 text-center text-sm text-muted-foreground">
|
||||
{rows.length === 0
|
||||
? "暂无问题配置,点击「新增问题」开始配置"
|
||||
: "没有匹配的问题或回答"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{visibleRows.map((row, index) => (
|
||||
<section
|
||||
key={row.id}
|
||||
className={`rounded-2xl border bg-background p-4 shadow-sm transition-opacity ${
|
||||
row.enabled ? "" : "opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<span className="flex size-6 items-center justify-center rounded-full bg-muted text-xs text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
固定问答
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>流式速度</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
step={10}
|
||||
value={row.tokensPerSecond}
|
||||
onChange={(event) =>
|
||||
patchRow(row.id, {
|
||||
tokensPerSecond: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
className="h-8 w-24"
|
||||
aria-label="流式返回速度"
|
||||
/>
|
||||
<span>token/s</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Switch
|
||||
checked={row.enabled}
|
||||
onCheckedChange={(enabled) =>
|
||||
patchRow(row.id, { enabled })
|
||||
}
|
||||
/>
|
||||
{row.enabled ? "已启用" : "已停用"}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
title="删除"
|
||||
onClick={() => removeRow(row.id)}
|
||||
className="flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1.5 text-sm font-medium">
|
||||
<span>问题</span>
|
||||
<Textarea
|
||||
value={row.question}
|
||||
onChange={(event) =>
|
||||
patchRow(row.id, { question: event.target.value })
|
||||
}
|
||||
placeholder="输入需要精确匹配的问题"
|
||||
className="min-h-28 resize-y"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5 text-sm font-medium">
|
||||
<span>回答</span>
|
||||
<Textarea
|
||||
value={row.answer}
|
||||
onChange={(event) =>
|
||||
patchRow(row.id, { answer: event.target.value })
|
||||
}
|
||||
placeholder="输入命中后流式返回的回答(支持 Markdown)"
|
||||
className="min-h-28 resize-y"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Loader2, Settings2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { Navigate, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
|
||||
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||
import WeKnoraKnowledgeWorkspace, {
|
||||
ConnectionSettings,
|
||||
@@ -66,7 +67,14 @@ export default function LlmWikiKnowledgePage({
|
||||
}: {
|
||||
mode?: LlmWikiKnowledgePageMode;
|
||||
}) {
|
||||
const { noteId = "" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const wikiSlug = searchParams.get("wikiSlug")?.trim() || "";
|
||||
|
||||
if (mode === "detail" && noteId && wikiSlug) {
|
||||
return <Navigate to={buildWeKnoraWikiPagePath(noteId, wikiSlug)} replace />;
|
||||
}
|
||||
|
||||
if (runtime.isLoading) {
|
||||
return <RuntimeLoading />;
|
||||
|
||||
@@ -47,6 +47,7 @@ import LightAppListPage from "./LightAppListPage";
|
||||
import LightAppManagementPage from "./LightAppManagementPage";
|
||||
import MenuManagementPage from "./MenuManagementPage";
|
||||
import LoginWhitelistPage from "./LoginWhitelistPage";
|
||||
import FixedQuestionsPage from "./FixedQuestionsPage";
|
||||
import SensitiveWordsPage from "./SensitiveWordsPage";
|
||||
import SkillAddressesPage from "./SkillAddressesPage";
|
||||
import TaskButtonsPage from "./TaskButtonsPage";
|
||||
@@ -454,6 +455,16 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/fixed-questions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<FixedQuestionsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/sensitive-words"
|
||||
element={
|
||||
|
||||
@@ -40,6 +40,7 @@ import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
|
||||
import ScheduledTasksPage from './ScheduledTasksPage';
|
||||
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
|
||||
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
|
||||
import WeKnoraWikiPageView from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView';
|
||||
import MarkdownDocxEditorPage from './MarkdownDocxEditorPage';
|
||||
import LlmMetricsPage from './LlmMetricsPage';
|
||||
import CheckpointMigrationPage from './CheckpointMigrationPage';
|
||||
@@ -678,6 +679,14 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="knowledge/:noteId/wiki/view"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<WeKnoraWikiPageView />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="knowledge/:noteId/wiki"
|
||||
element={
|
||||
|
||||
@@ -452,6 +452,11 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
// 跨 worker 单飞——重复 / 双击命中既有回合即幂等返回,杜绝并发下双发意图。
|
||||
const clientTurnId = createClientId();
|
||||
|
||||
// ask_clarification 是本轮的严格暂停点。某些模型在工具调用已经产出
|
||||
// 选项卡后还会继续吐一段“请做选择”等正文;从第一张卡出现起忽略后续
|
||||
// 文本 delta,但继续消费流以收齐同轮的其它澄清卡和终态帧。
|
||||
let clarificationSeen = false;
|
||||
|
||||
const res = await streamIntent({
|
||||
threadId: intentThreadId,
|
||||
message: trimmed,
|
||||
@@ -462,6 +467,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
files: fileRefs,
|
||||
signal: aborter.signal,
|
||||
onTextDelta: (delta) => {
|
||||
if (clarificationSeen) return;
|
||||
setInteractiveMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === agentMsgId
|
||||
@@ -504,6 +510,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
);
|
||||
},
|
||||
onClarification: (clarification, index) => {
|
||||
clarificationSeen = true;
|
||||
// 实时把每条澄清问题灌进气泡 —— 用户能边流边看到「协助过程」逐个出现,
|
||||
// 而不是等终态帧(终态帧只带第 1 个问题)。同时往步骤链补一条
|
||||
// 「追问 N:<问题>」,与「强化步骤链」诉求对齐。
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { LlmWikiSearchResult } from "./llmwiki";
|
||||
|
||||
const API = () => `${getBackendBaseURL()}/api/enterprise-research`;
|
||||
|
||||
/**
|
||||
* Enterprise research snapshots raw WeKnora evidence chunks. This is
|
||||
* intentionally separate from LlmWikiSearchResult, whose public search shape
|
||||
* now represents processed Wiki pages only.
|
||||
*/
|
||||
export interface EnterpriseResearchEvidence {
|
||||
chunk_id: string;
|
||||
content: string;
|
||||
knowledge_id: string;
|
||||
knowledge_base_id: string;
|
||||
knowledge_base_name: string;
|
||||
title: string;
|
||||
filename: string;
|
||||
score: number;
|
||||
chunk_index: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type EnterpriseResearchTaskStatus = "draft" | "collecting" | "ready" | "partial" | "failed";
|
||||
|
||||
export interface EnterpriseResearchTask {
|
||||
@@ -15,7 +31,7 @@ export interface EnterpriseResearchTask {
|
||||
knowledge_base_ids: string[];
|
||||
queries: string[];
|
||||
selected_source_ids: string[];
|
||||
sources: LlmWikiSearchResult[];
|
||||
sources: EnterpriseResearchEvidence[];
|
||||
plan_markdown?: string | null;
|
||||
report_markdown?: string | null;
|
||||
report_summary?: string | null;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/** Administrator-managed exact-match fixed question/answer API. */
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/fixed-questions`;
|
||||
|
||||
export interface FixedQuestion {
|
||||
id: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
enabled: boolean;
|
||||
tokensPerSecond: number;
|
||||
sortOrder: number;
|
||||
updatedBy?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
interface ServerFixedQuestion {
|
||||
id: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
enabled: boolean;
|
||||
tokens_per_second: number;
|
||||
sort_order: number;
|
||||
updated_by?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
function fromServer(row: ServerFixedQuestion): FixedQuestion {
|
||||
return {
|
||||
id: row.id,
|
||||
question: row.question ?? "",
|
||||
answer: row.answer ?? "",
|
||||
enabled: row.enabled ?? true,
|
||||
tokensPerSecond: row.tokens_per_second ?? 100,
|
||||
sortOrder: row.sort_order ?? 0,
|
||||
updatedBy: row.updated_by ?? null,
|
||||
updatedAt: row.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function toServer(row: FixedQuestion): ServerFixedQuestion {
|
||||
return {
|
||||
id: row.id,
|
||||
question: row.question,
|
||||
answer: row.answer,
|
||||
enabled: row.enabled,
|
||||
tokens_per_second: row.tokensPerSecond,
|
||||
sort_order: row.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response, context: string): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null as unknown);
|
||||
const detail =
|
||||
data && typeof data === "object" && "detail" in data
|
||||
? String((data as { detail: unknown }).detail)
|
||||
: `${context} failed (${response.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function listFixedQuestions(): Promise<FixedQuestion[]> {
|
||||
const response = await apiFetch(BASE(), { method: "GET" });
|
||||
const data = await readJson<{ questions: ServerFixedQuestion[] }>(
|
||||
response,
|
||||
"list fixed questions",
|
||||
);
|
||||
return (data.questions ?? []).map(fromServer);
|
||||
}
|
||||
|
||||
export async function replaceFixedQuestions(
|
||||
questions: FixedQuestion[],
|
||||
): Promise<FixedQuestion[]> {
|
||||
const response = await apiFetch(BASE(), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ questions: questions.map(toServer) }),
|
||||
});
|
||||
const data = await readJson<{ questions: ServerFixedQuestion[] }>(
|
||||
response,
|
||||
"save fixed questions",
|
||||
);
|
||||
const saved = (data.questions ?? []).map(fromServer);
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "CM助手-全量-问题配置-保存",
|
||||
url: "/api/fixed-questions",
|
||||
reqParam: { questions },
|
||||
returnParam: saved,
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
const FIXED_QUESTIONS_KEY = "fixed-questions";
|
||||
|
||||
export function useFixedQuestions() {
|
||||
return useQuery({
|
||||
queryKey: [FIXED_QUESTIONS_KEY],
|
||||
queryFn: listFixedQuestions,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useFixedQuestionActions() {
|
||||
const queryClient = useQueryClient();
|
||||
return {
|
||||
save: useMutation({
|
||||
mutationFn: replaceFixedQuestions,
|
||||
onSuccess: (questions) => {
|
||||
queryClient.setQueryData([FIXED_QUESTIONS_KEY], questions);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
const API = () => `${getBackendBaseURL()}/api/llmwiki`;
|
||||
const PUBLIC_API = () => `${getBackendBaseURL()}/api/public/llmwiki`;
|
||||
const SETTINGS_API = () => `${getBackendBaseURL()}/api/system-settings/llmwiki`;
|
||||
|
||||
export type LlmWikiProvider = "legacy" | "weknora";
|
||||
@@ -143,6 +144,22 @@ export interface LlmWikiWikiPageList {
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface LlmWikiWikiIndexStatus {
|
||||
knowledge_base_id: string;
|
||||
state: "not_synced" | "idle" | "running" | "partial" | "failed" | "rebuilding" | "rebuild_required";
|
||||
local_index_enabled: boolean;
|
||||
wiki_index_enabled: boolean;
|
||||
fully_vectorized: boolean;
|
||||
retrieval_mode: "vector" | "wiki_api";
|
||||
local_page_count?: number;
|
||||
ready_page_count?: number;
|
||||
failed_page_count?: number;
|
||||
vector_count?: number;
|
||||
last_error?: string | null;
|
||||
last_started_at?: string | null;
|
||||
last_completed_at?: string | null;
|
||||
}
|
||||
|
||||
export interface LlmWikiSourceContext {
|
||||
knowledge_base_name?: string;
|
||||
document_title?: string;
|
||||
@@ -159,16 +176,38 @@ export interface LlmWikiSourceContext {
|
||||
}
|
||||
|
||||
export interface LlmWikiSearchResult {
|
||||
chunk_id: string;
|
||||
kind: "wiki_page";
|
||||
id: string;
|
||||
wiki_page_id: string;
|
||||
wiki_slug: string;
|
||||
content: string;
|
||||
knowledge_id: string;
|
||||
knowledge_base_id: string;
|
||||
knowledge_base_name: string;
|
||||
title: string;
|
||||
filename: string;
|
||||
summary?: string;
|
||||
page_type?: string;
|
||||
page_version?: number;
|
||||
updated_at?: string | null;
|
||||
matched_heading?: string | null;
|
||||
matched_sections?: Array<{
|
||||
section_index: number;
|
||||
heading?: string | null;
|
||||
content: string;
|
||||
score: number;
|
||||
}>;
|
||||
score: number;
|
||||
chunk_index: number;
|
||||
url: string;
|
||||
retrieval_mode?: "vector" | "wiki_api";
|
||||
}
|
||||
|
||||
export interface LlmWikiSearchResponse {
|
||||
query: string;
|
||||
mode: "vector" | "wiki_api" | "mixed";
|
||||
results: LlmWikiSearchResult[];
|
||||
knowledge_base_count: number;
|
||||
partial: boolean;
|
||||
warnings: string[];
|
||||
fallback_knowledge_base_ids: string[];
|
||||
}
|
||||
|
||||
export interface LlmWikiWeKnoraEmbedSession {
|
||||
@@ -203,14 +242,22 @@ export interface LlmWikiWikiPageUpdate {
|
||||
wiki_path?: string;
|
||||
}
|
||||
|
||||
function userFacingKnowledgeMessage(value: string): string {
|
||||
return value.replace(/weknora/gi, "知识服务");
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response, context: string): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null as unknown);
|
||||
const detail =
|
||||
payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail: unknown }).detail)
|
||||
const rawDetail = payload && typeof payload === "object" && "detail" in payload
|
||||
? (payload as { detail: unknown }).detail
|
||||
: null;
|
||||
const detail = rawDetail && typeof rawDetail === "object" && "message" in rawDetail
|
||||
? String((rawDetail as { message: unknown }).message)
|
||||
: rawDetail
|
||||
? String(rawDetail)
|
||||
: `${context}失败(${response.status})`;
|
||||
throw new Error(detail);
|
||||
throw new Error(userFacingKnowledgeMessage(detail));
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return (await response.json()) as T;
|
||||
@@ -266,7 +313,7 @@ export async function createLlmWikiWeKnoraEmbedSession(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(id)}/weknora/session?tab=${encodeURIComponent(tab)}`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
"创建 WeKnora 详情会话",
|
||||
"创建知识库详情会话",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -412,6 +459,66 @@ export async function getLlmWikiWikiPage(
|
||||
);
|
||||
}
|
||||
|
||||
export async function getLlmWikiWikiIndexStatus(
|
||||
knowledgeBaseId: string,
|
||||
): Promise<LlmWikiWikiIndexStatus> {
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki-index-status`,
|
||||
),
|
||||
"读取 Wiki 向量化状态",
|
||||
);
|
||||
}
|
||||
|
||||
export async function vectorizeLlmWikiKnowledgeBase(
|
||||
knowledgeBaseId: string,
|
||||
): Promise<{ job_id: string; status: string; knowledge_base_id: string }> {
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/vectorize`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
"触发 Wiki 向量化",
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportLlmWikiWikiExcel(
|
||||
knowledgeBaseId: string,
|
||||
knowledgeBaseName = "LLMWiki",
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/export.xlsx`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
await readJson<void>(response, "导出 Wiki Excel");
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = objectUrl;
|
||||
link.download = `${knowledgeBaseName.replace(/[\\/:*?"<>|]/g, "_") || "LLMWiki"}-Wiki.xlsx`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
/** Read the DeerFlow-local Wiki mirror used by new vector citations. */
|
||||
export async function getLocalLlmWikiWikiPage(
|
||||
knowledgeBaseId: string,
|
||||
slug: string,
|
||||
): Promise<LlmWikiWikiPage> {
|
||||
const safeSlug = slug.split("/").map(encodeURIComponent).join("/");
|
||||
const page = await readJson<LlmWikiWikiPage & { page_version?: number }>(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/local-wiki/pages/${safeSlug}`,
|
||||
),
|
||||
"读取本地 Wiki 页面详情",
|
||||
);
|
||||
return { ...page, version: page.version ?? page.page_version ?? 0 };
|
||||
}
|
||||
|
||||
export async function createLlmWikiWikiPage(
|
||||
knowledgeBaseId: string,
|
||||
input: LlmWikiWikiPageCreate,
|
||||
@@ -554,8 +661,8 @@ export async function searchLlmWiki(input: {
|
||||
query: string;
|
||||
scope: LlmWikiScope;
|
||||
knowledge_base_ids?: string[];
|
||||
}): Promise<LlmWikiSearchResult[]> {
|
||||
const payload = await readJson<{ results: LlmWikiSearchResult[] }>(
|
||||
}): Promise<LlmWikiSearchResponse> {
|
||||
return readJson<LlmWikiSearchResponse>(
|
||||
await apiFetch(`${API()}/search`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -563,7 +670,44 @@ export async function searchLlmWiki(input: {
|
||||
}),
|
||||
"检索知识库",
|
||||
);
|
||||
return payload.results ?? [];
|
||||
}
|
||||
|
||||
export async function getPublicLlmWikiKnowledgeBase(
|
||||
id: string,
|
||||
): Promise<LlmWikiKnowledgeBase> {
|
||||
return readJson(
|
||||
await apiFetch(`${PUBLIC_API()}/knowledge-bases/${encodeURIComponent(id)}`),
|
||||
"读取公开知识库详情",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listPublicLlmWikiWikiPages(
|
||||
knowledgeBaseId: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
query = "",
|
||||
): Promise<LlmWikiWikiPageList> {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||
if (query.trim()) params.set("query", query.trim());
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${PUBLIC_API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages?${params.toString()}`,
|
||||
),
|
||||
"读取公开 Wiki 页面",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPublicLlmWikiWikiPage(
|
||||
knowledgeBaseId: string,
|
||||
slug: string,
|
||||
): Promise<LlmWikiWikiPage> {
|
||||
const safeSlug = slug.split("/").map(encodeURIComponent).join("/");
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${PUBLIC_API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages/${safeSlug}`,
|
||||
),
|
||||
"读取公开 Wiki 页面详情",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createLlmWikiConversationDeposit(input: {
|
||||
@@ -586,7 +730,7 @@ export async function createLlmWikiConversationDeposit(input: {
|
||||
}
|
||||
|
||||
export async function getLlmWikiSettings(): Promise<LlmWikiSettings> {
|
||||
return readJson(await apiFetch(SETTINGS_API()), "读取 WeKnora 连接配置");
|
||||
return readJson(await apiFetch(SETTINGS_API()), "读取知识服务连接配置");
|
||||
}
|
||||
|
||||
export async function saveLlmWikiSettings(input: {
|
||||
@@ -600,7 +744,7 @@ export async function saveLlmWikiSettings(input: {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
"保存 WeKnora 连接配置",
|
||||
"保存知识服务连接配置",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -620,7 +764,7 @@ export async function testLlmWikiConnection(
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ api_base_url: apiBaseUrl }),
|
||||
}),
|
||||
"测试 WeKnora 连接",
|
||||
"测试知识服务连接",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -755,6 +899,62 @@ export function useLlmWikiWikiPage(
|
||||
});
|
||||
}
|
||||
|
||||
export function usePublicLlmWikiKnowledgeBase(
|
||||
knowledgeBaseId: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["public-llmwiki", "knowledge-base", knowledgeBaseId],
|
||||
queryFn: () => getPublicLlmWikiKnowledgeBase(knowledgeBaseId!),
|
||||
enabled: enabled && !!knowledgeBaseId,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePublicLlmWikiWikiPages(
|
||||
knowledgeBaseId: string | null,
|
||||
page: number,
|
||||
pageSize = 50,
|
||||
query = "",
|
||||
enabled = true,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["public-llmwiki", "wiki-pages", knowledgeBaseId, page, pageSize, query],
|
||||
queryFn: () => listPublicLlmWikiWikiPages(knowledgeBaseId!, page, pageSize, query),
|
||||
enabled: enabled && !!knowledgeBaseId,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePublicLlmWikiWikiPage(
|
||||
knowledgeBaseId: string | null,
|
||||
slug: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["public-llmwiki", "wiki-page", knowledgeBaseId, slug],
|
||||
queryFn: () => getPublicLlmWikiWikiPage(knowledgeBaseId!, slug!),
|
||||
enabled: enabled && !!knowledgeBaseId && !!slug,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiWikiIndexStatus(
|
||||
knowledgeBaseId: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "wiki-index-status", knowledgeBaseId, userId ?? "anonymous"],
|
||||
queryFn: () => getLlmWikiWikiIndexStatus(knowledgeBaseId!),
|
||||
enabled: enabled && !!knowledgeBaseId,
|
||||
refetchInterval: (query) => {
|
||||
const state = query.state.data?.state;
|
||||
return state === "running" || state === "rebuilding" ? 2_000 : 10_000;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiSettings(enabled = true) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
@@ -822,6 +1022,14 @@ export function useLlmWikiActions() {
|
||||
deleteLlmWikiWikiPage(id, slug),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
vectorizeWiki: useMutation({
|
||||
mutationFn: ({ id }: { id: string }) => vectorizeLlmWikiKnowledgeBase(id),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
exportWikiExcel: useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name?: string }) =>
|
||||
exportLlmWikiWikiExcel(id, name),
|
||||
}),
|
||||
publication: useMutation({
|
||||
mutationFn: ({ id, publish }: { id: string; publish: boolean }) =>
|
||||
setLlmWikiPublication(id, publish),
|
||||
|
||||
+16
-66
@@ -1,4 +1,4 @@
|
||||
import { AlertCircle, Edit3, FileText, Loader2, RefreshCw, Trash2, Upload } from "lucide-react";
|
||||
import { AlertCircle, Edit3, FileText, Loader2, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
@@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownCodeEditor } from "@/components/wiki/MarkdownCodeEditor";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
|
||||
import {
|
||||
createLlmWikiWeKnoraEmbedSession,
|
||||
getLlmWikiSourceContext,
|
||||
@@ -100,14 +101,11 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [frameSrc, setFrameSrc] = useState("");
|
||||
const [frameVersion, setFrameVersion] = useState(0);
|
||||
const [frameError, setFrameError] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const details = useLlmWikiKnowledgeBase(knowledgeBaseId || null, !!knowledgeBaseId);
|
||||
const actions = useLlmWikiActions();
|
||||
|
||||
const detailsReady = !!details.data;
|
||||
const requestedTab = searchParams.get("tab");
|
||||
@@ -120,12 +118,15 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
const sourceChunkId = searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
|
||||
const documentId = searchParams.get("documentId") || "";
|
||||
const wikiSlug = searchParams.get("wikiSlug") || "";
|
||||
const nativeWikiPageUrl = knowledgeBaseId && wikiSlug && preferredTab === "wiki"
|
||||
? buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)
|
||||
: "";
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setFrameSrc("");
|
||||
setFrameError(null);
|
||||
if (!knowledgeBaseId || runtime.data?.provider !== "weknora" || !detailsReady) {
|
||||
if (nativeWikiPageUrl || !knowledgeBaseId || runtime.data?.provider !== "weknora" || !detailsReady) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -158,7 +159,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [detailsReady, documentId, knowledgeBaseId, preferredTab, runtime.data?.provider, sourceChunkId, wikiSlug]);
|
||||
}, [detailsReady, documentId, knowledgeBaseId, nativeWikiPageUrl, preferredTab, runtime.data?.provider, sourceChunkId, wikiSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!frameSrc) return;
|
||||
@@ -178,6 +179,10 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
};
|
||||
}, [frameSrc]);
|
||||
|
||||
if (nativeWikiPageUrl) {
|
||||
return <Navigate to={nativeWikiPageUrl} replace />;
|
||||
}
|
||||
|
||||
if (runtime.isLoading || details.isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
@@ -225,53 +230,15 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const canUpload = details.data.can_write && !details.data.is_conversation_deposit;
|
||||
const canWrite = details.data.can_write && !details.data.is_conversation_deposit;
|
||||
const canEditCurrentWikiArticle =
|
||||
canUpload && !!details.data.indexing_strategy?.wiki_enabled && !!wikiSlug;
|
||||
const canManageWiki = canUpload && !!details.data.indexing_strategy?.wiki_enabled;
|
||||
canWrite && !!details.data.indexing_strategy?.wiki_enabled && !!wikiSlug;
|
||||
const canManageWiki = canWrite && !!details.data.indexing_strategy?.wiki_enabled;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
{(canUpload || canEditCurrentWikiArticle) && (
|
||||
{canManageWiki && (
|
||||
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.currentTarget.files ?? []);
|
||||
event.currentTarget.value = "";
|
||||
if (files.length === 0) return;
|
||||
void (async () => {
|
||||
setUploading(true);
|
||||
const failed: string[] = [];
|
||||
try {
|
||||
for (const file of files) {
|
||||
try {
|
||||
await actions.upload.mutateAsync({ id: knowledgeBaseId, file });
|
||||
} catch {
|
||||
failed.push(file.name);
|
||||
}
|
||||
}
|
||||
const succeeded = files.length - failed.length;
|
||||
if (succeeded > 0) {
|
||||
await details.refetch();
|
||||
setFrameVersion((value) => value + 1);
|
||||
}
|
||||
if (failed.length === 0) {
|
||||
toast.success(`已上传 ${succeeded} 个文件,正在处理`);
|
||||
} else if (succeeded > 0) {
|
||||
toast.warning(`上传完成:成功 ${succeeded} 个,失败 ${failed.length} 个`);
|
||||
} else {
|
||||
toast.error("文件上传失败");
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
{canEditCurrentWikiArticle && (
|
||||
<CurrentWikiArticleActions
|
||||
@@ -288,7 +255,6 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{canManageWiki && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -297,22 +263,6 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
<FileText className="mr-2 size-4" />
|
||||
Wiki管理
|
||||
</Button>
|
||||
)}
|
||||
{canUpload && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 size-4" />
|
||||
)}
|
||||
上传文件
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -323,7 +273,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
title={`${details.data.name || "知识库"} 详情`}
|
||||
src={frameSrc}
|
||||
className="h-full min-h-0 w-full flex-1 border-0 bg-background"
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-popups"
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-popups allow-popups-to-escape-sandbox"
|
||||
referrerPolicy="no-referrer"
|
||||
onLoad={() => postThemeToFrame(frameRef.current)}
|
||||
/>
|
||||
|
||||
+141
-15
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
BookOpen,
|
||||
BookOpenText,
|
||||
ChevronRight,
|
||||
Database,
|
||||
FileText,
|
||||
Globe2,
|
||||
@@ -29,8 +31,11 @@ import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||
import {
|
||||
type LlmWikiKnowledgeBase,
|
||||
type LlmWikiSearchResponse,
|
||||
type LlmWikiSearchResult,
|
||||
type LlmWikiScope,
|
||||
searchLlmWiki,
|
||||
useLlmWikiActions,
|
||||
@@ -53,6 +58,49 @@ function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "操作失败,请稍后重试";
|
||||
}
|
||||
|
||||
function searchModeLabel(mode: LlmWikiSearchResponse["mode"]) {
|
||||
if (mode === "vector") return "本地 Wiki 向量检索";
|
||||
if (mode === "mixed") return "Wiki 混合检索";
|
||||
return "Wiki 接口检索";
|
||||
}
|
||||
|
||||
function wikiPageTypeLabel(value?: string) {
|
||||
const normalized = (value || "").trim().toLowerCase();
|
||||
if (normalized === "directory") return "目录页";
|
||||
if (normalized === "overview") return "概览页";
|
||||
if (normalized === "article") return "文章页";
|
||||
return value?.trim() || "Wiki 页面";
|
||||
}
|
||||
|
||||
function formatSearchTime(value?: string | null) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? value
|
||||
: date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function resultPreview(result: LlmWikiSearchResult) {
|
||||
const text = (
|
||||
result.matched_sections?.map((section) => section.content).filter(Boolean).join("\n\n") ||
|
||||
result.summary ||
|
||||
result.content ||
|
||||
""
|
||||
);
|
||||
return text
|
||||
.replace(/^---[\s\S]*?---\s*/m, "")
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
|
||||
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/^\s*[-*+]\s+/gm, "")
|
||||
.replace(/[>*_`~|]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function WeKnoraKnowledgeWorkspace() {
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
@@ -63,15 +111,13 @@ export function WeKnoraKnowledgeWorkspace() {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<Array<{
|
||||
chunk_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
score: number;
|
||||
knowledge_base_name: string;
|
||||
}>>([]);
|
||||
const [searchResponse, setSearchResponse] = useState<LlmWikiSearchResponse | null>(null);
|
||||
const [selectedSearchResult, setSelectedSearchResult] = useState<LlmWikiSearchResult | null>(null);
|
||||
|
||||
useEffect(() => setSearchResults([]), [tab]);
|
||||
useEffect(() => {
|
||||
setSearchResponse(null);
|
||||
setSelectedSearchResult(null);
|
||||
}, [tab]);
|
||||
|
||||
const busy = actions.create.isPending || actions.remove.isPending || actions.publication.isPending;
|
||||
const openDetails = (item: LlmWikiKnowledgeBase) => {
|
||||
@@ -87,7 +133,7 @@ export function WeKnoraKnowledgeWorkspace() {
|
||||
if (!query) return;
|
||||
setSearching(true);
|
||||
try {
|
||||
setSearchResults(await searchLlmWiki({ query, scope }));
|
||||
setSearchResponse(await searchLlmWiki({ query, scope }));
|
||||
} catch (error) {
|
||||
toast.error(messageOf(error));
|
||||
} finally {
|
||||
@@ -154,9 +200,9 @@ export function WeKnoraKnowledgeWorkspace() {
|
||||
<div className="flex w-full max-w-xl items-center gap-2 rounded-xl border bg-background/80 p-1 shadow-sm sm:w-auto">
|
||||
<div className="relative min-w-0 flex-1 sm:w-80">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input value={searchText} onChange={(event) => setSearchText(event.target.value)} onKeyDown={(event) => event.key === "Enter" && void runSearch()} placeholder={tab === "public" ? "检索公共知识" : "检索我的知识"} className="border-0 bg-transparent pl-9 shadow-none focus-visible:ring-0" />
|
||||
<Input value={searchText} onChange={(event) => setSearchText(event.target.value)} onKeyDown={(event) => event.key === "Enter" && void runSearch()} placeholder={tab === "public" ? "检索公共 Wiki 文档" : "检索我的 Wiki 文档"} className="border-0 bg-transparent pl-9 shadow-none focus-visible:ring-0" />
|
||||
</div>
|
||||
<Button variant="secondary" className="rounded-lg px-4" onClick={() => void runSearch()} disabled={searching || !searchText.trim()}>{searching ? <Loader2 className="size-4 animate-spin" /> : "检索"}</Button>
|
||||
<Button variant="secondary" className="rounded-lg px-4" onClick={() => void runSearch()} disabled={searching || !searchText.trim()}>{searching ? <Loader2 className="size-4 animate-spin" /> : "检索 Wiki"}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -166,15 +212,95 @@ export function WeKnoraKnowledgeWorkspace() {
|
||||
{isAdmin && <TabsContent value="settings" className="mt-5"><ConnectionSettings /></TabsContent>}
|
||||
</Tabs>
|
||||
|
||||
{searchResults.length > 0 && tab !== "settings" && (
|
||||
<Card className="mt-5">
|
||||
<CardHeader className="pb-3"><CardTitle className="text-base">检索结果</CardTitle><CardDescription>结果由知识服务检索返回,问答中使用同一授权链路。</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-3">{searchResults.map((result) => <div key={result.chunk_id} className="rounded-lg border p-3"><div className="flex items-center justify-between gap-3 text-sm font-medium"><span>{result.title || "未命名来源"}</span><Badge variant="outline">{result.knowledge_base_name}</Badge></div><p className="mt-2 line-clamp-3 whitespace-pre-wrap text-sm text-muted-foreground">{result.content}</p></div>)}</CardContent>
|
||||
{searchResponse && tab !== "settings" && (
|
||||
<Card className="mt-5 overflow-hidden border-emerald-200/70 bg-gradient-to-br from-emerald-50/70 via-card to-card shadow-sm dark:border-emerald-900/60 dark:from-emerald-950/20">
|
||||
<CardHeader className="border-b border-emerald-200/60 pb-4 dark:border-emerald-900/50">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-emerald-600 text-white shadow-sm shadow-emerald-600/20">
|
||||
<BookOpenText className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
|
||||
Wiki 文档检索
|
||||
<Badge className="border-emerald-200 bg-emerald-100 text-emerald-800 hover:bg-emerald-100 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">
|
||||
{searchResponse.results.length} 个 Wiki 页面
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5 leading-5">
|
||||
本次直接检索知识库生成的 Wiki 页面,不检索上传原文或原文分块。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0 bg-background/80 text-emerald-700 dark:text-emerald-300">
|
||||
{searchModeLabel(searchResponse.mode)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{searchResponse.results.length === 0 ? (
|
||||
<div className="flex min-h-36 flex-col items-center justify-center rounded-xl border border-dashed bg-background/70 px-6 text-center">
|
||||
<BookOpenText className="mb-3 size-8 text-muted-foreground/60" />
|
||||
<div className="text-sm font-medium">已完成 Wiki 文档检索,暂未命中相关页面</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">可以缩短关键词或换一个主题后重试。</div>
|
||||
</div>
|
||||
) : searchResponse.results.map((result) => {
|
||||
const heading = result.matched_heading || result.matched_sections?.find((section) => section.heading)?.heading;
|
||||
const updatedAt = formatSearchTime(result.updated_at);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`${result.knowledge_base_id}:${result.wiki_slug || result.id}`}
|
||||
className="group w-full rounded-xl border bg-background/90 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-300 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/40"
|
||||
onClick={() => setSelectedSearchResult(result)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
|
||||
<BookOpenText className="size-4.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">Wiki 文档</Badge>
|
||||
<Badge variant="outline">{wikiPageTypeLabel(result.page_type)}</Badge>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.knowledge_base_name}</span>
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-2 text-[15px] font-semibold leading-6 text-foreground">{result.title || "未命名 Wiki 页面"}</div>
|
||||
{heading && <div className="mt-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">命中章节:{heading}</div>}
|
||||
<p className="mt-2 line-clamp-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{resultPreview(result)}</p>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span>相关度 {Math.round(Math.max(0, result.score || 0) * 100)}%</span>
|
||||
{updatedAt && <span>更新于 {updatedAt}</span>}
|
||||
</div>
|
||||
<span className="inline-flex items-center font-medium text-emerald-700 transition group-hover:translate-x-0.5 dark:text-emerald-300">
|
||||
抽屉查看完整 Wiki <ChevronRight className="ml-1 size-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WeKnoraSourceDrawer
|
||||
open={selectedSearchResult !== null}
|
||||
target={selectedSearchResult ? {
|
||||
knowledgeBaseId: selectedSearchResult.knowledge_base_id,
|
||||
wikiSlug: selectedSearchResult.wiki_slug,
|
||||
tab: "wiki",
|
||||
} : null}
|
||||
title={selectedSearchResult?.title}
|
||||
quote={selectedSearchResult ? resultPreview(selectedSearchResult) : ""}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedSearchResult(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CreateKnowledgeDialog open={createOpen} pending={actions.create.isPending} onOpenChange={setCreateOpen} onCreate={async (input) => {
|
||||
try {
|
||||
const created = await actions.create.mutateAsync(input);
|
||||
|
||||
+189
-54
@@ -1,5 +1,14 @@
|
||||
import { Download, FileText, Loader2, Maximize2, Minimize2 } from "lucide-react";
|
||||
import {
|
||||
BookOpenText,
|
||||
Download,
|
||||
FileDown,
|
||||
FileText,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -17,10 +26,21 @@ import { SpreadsheetPreview } from "@/components/workspace/artifacts/spreadsheet
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
|
||||
import {
|
||||
exportableWikiMarkdown,
|
||||
renderableWikiMarkdown,
|
||||
safeWikiFileName,
|
||||
stripWikiFrontmatter,
|
||||
wikiLinkMarkdown,
|
||||
} from "@/core/llmwiki/wiki-page-markdown";
|
||||
import { markdownToDocxBlob } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import {
|
||||
getLlmWikiDocument,
|
||||
getLlmWikiDocumentPreview,
|
||||
getLlmWikiWikiIndexStatus,
|
||||
getLlmWikiWikiPage,
|
||||
getLocalLlmWikiWikiPage,
|
||||
listLlmWikiDocumentChunks,
|
||||
type LlmWikiChunk,
|
||||
type LlmWikiDocument,
|
||||
@@ -61,8 +81,34 @@ type CachedSourceDetail = {
|
||||
|
||||
const sourceDetailCache = new Map<string, CachedSourceDetail>();
|
||||
|
||||
async function getAvailableWikiPage(
|
||||
knowledgeBaseId: string,
|
||||
wikiSlug: string,
|
||||
): Promise<LlmWikiWikiPage> {
|
||||
const status = await getLlmWikiWikiIndexStatus(knowledgeBaseId).catch(() => null);
|
||||
if (status?.local_index_enabled) {
|
||||
return getLocalLlmWikiWikiPage(knowledgeBaseId, wikiSlug).catch(() =>
|
||||
getLlmWikiWikiPage(knowledgeBaseId, wikiSlug),
|
||||
);
|
||||
}
|
||||
if (status?.local_index_enabled === false) {
|
||||
return getLlmWikiWikiPage(knowledgeBaseId, wikiSlug);
|
||||
}
|
||||
try {
|
||||
return await getLlmWikiWikiPage(knowledgeBaseId, wikiSlug);
|
||||
} catch (remoteError) {
|
||||
try {
|
||||
return await getLocalLlmWikiWikiPage(knowledgeBaseId, wikiSlug);
|
||||
} catch {
|
||||
// Do not mask the useful remote-page error with a local-index-disabled
|
||||
// response when the status request itself was unavailable.
|
||||
throw remoteError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTargetDetail(target: WeKnoraSourceTarget): Promise<DetailTarget> {
|
||||
if (target.documentId || target.wikiSlug || !target.chunkId) {
|
||||
if (target.wikiSlug || !target.chunkId) {
|
||||
return target;
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
@@ -74,12 +120,14 @@ async function resolveTargetDetail(target: WeKnoraSourceTarget): Promise<DetailT
|
||||
);
|
||||
const payload = (await response.json().catch(() => null)) as SourceContextResponse | null;
|
||||
if (!response.ok || payload?.success !== true) {
|
||||
throw new Error("无法定位原文文档详情");
|
||||
if (target.documentId) return target;
|
||||
throw new Error("无法定位当前引用详情");
|
||||
}
|
||||
const documentId = payload.data?.document_id?.trim();
|
||||
const wikiSlug = payload.data?.wiki_page?.slug?.trim();
|
||||
if (!documentId && !wikiSlug) {
|
||||
throw new Error("当前引用缺少原文文档标识,无法打开详情");
|
||||
if (target.documentId) return target;
|
||||
throw new Error("当前引用缺少 Wiki 页面或文档标识,无法打开详情");
|
||||
}
|
||||
return {
|
||||
...target,
|
||||
@@ -96,8 +144,14 @@ function formatDate(value?: string | null) {
|
||||
}
|
||||
|
||||
function displayType(document?: LlmWikiDocument | null, wiki?: LlmWikiWikiPage | null) {
|
||||
if (wiki) {
|
||||
const pageType = String(wiki.page_type || "").toLowerCase();
|
||||
if (pageType === "directory") return "Wiki 目录页";
|
||||
if (pageType === "overview") return "Wiki 概览页";
|
||||
if (pageType === "article") return "Wiki 文章页";
|
||||
return "Wiki 文档";
|
||||
}
|
||||
return (
|
||||
wiki?.page_type ||
|
||||
document?.file_type ||
|
||||
document?.type ||
|
||||
document?.source ||
|
||||
@@ -109,8 +163,23 @@ function cleanText(value?: string | null) {
|
||||
return (value || "").replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
|
||||
function stripFrontmatter(value: string) {
|
||||
return value.replace(/^\s*---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, "").trim();
|
||||
function downloadMarkdown(markdown: string, fileName: string) {
|
||||
downloadBlob(
|
||||
new Blob([markdown], { type: "text/markdown;charset=utf-8" }),
|
||||
`${safeWikiFileName(fileName)}.md`,
|
||||
);
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = window.document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.rel = "noopener";
|
||||
window.document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
}
|
||||
|
||||
function fileExtension(document?: LlmWikiDocument | null) {
|
||||
@@ -183,7 +252,7 @@ function isWordDocument(document?: LlmWikiDocument | null) {
|
||||
}
|
||||
|
||||
function markdownFromChunks(chunks: LlmWikiChunk[]) {
|
||||
return stripFrontmatter(
|
||||
return stripWikiFrontmatter(
|
||||
chunks.map((chunk) => cleanText(chunk.content)).filter(Boolean).join("\n\n"),
|
||||
);
|
||||
}
|
||||
@@ -468,6 +537,7 @@ export function WeKnoraSourceDrawer({
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [wikiDownload, setWikiDownload] = useState<"md" | "word" | null>(null);
|
||||
const [resolvedDocumentId, setResolvedDocumentId] = useState("");
|
||||
|
||||
const targetKey = useMemo(() => {
|
||||
@@ -518,7 +588,9 @@ export function WeKnoraSourceDrawer({
|
||||
|
||||
void (async () => {
|
||||
const detailTarget = await resolveTargetDetail(target);
|
||||
if (!cancelled) setResolvedDocumentId(detailTarget.documentId || "");
|
||||
const isWikiTarget = Boolean(detailTarget.wikiSlug);
|
||||
const previewDocumentId = isWikiTarget ? "" : detailTarget.documentId || "";
|
||||
if (!cancelled) setResolvedDocumentId(previewDocumentId);
|
||||
const tasks: Promise<void>[] = [];
|
||||
let loadedDocument: LlmWikiDocument | null = null;
|
||||
let loadedWikiPage: LlmWikiWikiPage | null = null;
|
||||
@@ -528,24 +600,23 @@ export function WeKnoraSourceDrawer({
|
||||
|
||||
if (detailTarget.wikiSlug) {
|
||||
tasks.push(
|
||||
getLlmWikiWikiPage(detailTarget.knowledgeBaseId, detailTarget.wikiSlug)
|
||||
getAvailableWikiPage(detailTarget.knowledgeBaseId, detailTarget.wikiSlug)
|
||||
.then((page) => {
|
||||
loadedWikiPage = page;
|
||||
if (!cancelled) setWikiPage(page);
|
||||
})
|
||||
.catch(() => undefined),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (detailTarget.documentId) {
|
||||
if (previewDocumentId) {
|
||||
loadedDocument = await getLlmWikiDocument(
|
||||
detailTarget.knowledgeBaseId,
|
||||
detailTarget.documentId,
|
||||
previewDocumentId,
|
||||
);
|
||||
if (!cancelled) setDocument(loadedDocument);
|
||||
loadedChunks = await listLlmWikiDocumentChunks(
|
||||
detailTarget.knowledgeBaseId,
|
||||
detailTarget.documentId,
|
||||
previewDocumentId,
|
||||
1,
|
||||
200,
|
||||
)
|
||||
@@ -557,7 +628,7 @@ export function WeKnoraSourceDrawer({
|
||||
}
|
||||
const previewBlobPromise = isWordDocument(loadedDocument)
|
||||
? Promise.resolve(new Blob())
|
||||
: getLlmWikiDocumentPreview(detailTarget.knowledgeBaseId, detailTarget.documentId);
|
||||
: getLlmWikiDocumentPreview(detailTarget.knowledgeBaseId, previewDocumentId);
|
||||
const previewTask = previewBlobPromise
|
||||
.then((blob) => {
|
||||
return buildPreviewState(
|
||||
@@ -585,11 +656,11 @@ export function WeKnoraSourceDrawer({
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
throw new Error("当前引用缺少原文文档标识,无法打开详情");
|
||||
throw new Error("当前引用缺少 Wiki 页面或文档标识,无法打开详情");
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
sourceDetailCache.set(targetKey, {
|
||||
documentId: detailTarget.documentId || "",
|
||||
documentId: previewDocumentId,
|
||||
document: loadedDocument,
|
||||
wikiPage: loadedWikiPage,
|
||||
preview: loadedPreview,
|
||||
@@ -617,7 +688,20 @@ export function WeKnoraSourceDrawer({
|
||||
title ||
|
||||
"文档详情";
|
||||
const summary = cleanText(wikiPage?.summary || document?.description);
|
||||
const wikiContent = stripFrontmatter(cleanText(wikiPage?.content));
|
||||
const wikiContent = stripWikiFrontmatter(cleanText(wikiPage?.content));
|
||||
const wikiDisplayContent = wikiPage && target
|
||||
? renderableWikiMarkdown(resolvedTitle, wikiContent, target.knowledgeBaseId)
|
||||
: "";
|
||||
const wikiMarkdown = wikiPage
|
||||
? wikiLinkMarkdown(
|
||||
exportableWikiMarkdown(resolvedTitle, summary, wikiContent),
|
||||
target?.knowledgeBaseId || "",
|
||||
)
|
||||
: "";
|
||||
const resolvedWikiSlug = wikiPage?.slug || target?.wikiSlug || "";
|
||||
const wikiWorkspacePath = target && resolvedWikiSlug
|
||||
? buildWeKnoraWikiPagePath(target.knowledgeBaseId, resolvedWikiSlug)
|
||||
: "";
|
||||
|
||||
const downloadOriginal = async () => {
|
||||
if (!target || !resolvedDocumentId) return;
|
||||
@@ -639,6 +723,28 @@ export function WeKnoraSourceDrawer({
|
||||
}
|
||||
};
|
||||
|
||||
const downloadWiki = async (format: "md" | "word") => {
|
||||
if (!wikiMarkdown) {
|
||||
toast.error("当前 Wiki 页面没有可下载内容");
|
||||
return;
|
||||
}
|
||||
setWikiDownload(format);
|
||||
try {
|
||||
if (format === "md") {
|
||||
downloadMarkdown(wikiMarkdown, resolvedTitle);
|
||||
toast.success("Markdown 文件已生成");
|
||||
} else {
|
||||
const blob = await markdownToDocxBlob(wikiMarkdown, resolvedTitle);
|
||||
downloadBlob(blob, `${safeWikiFileName(resolvedTitle)}.docx`);
|
||||
toast.success("Word 文件已生成");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "下载 Wiki 页面失败");
|
||||
} finally {
|
||||
setWikiDownload(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
@@ -649,48 +755,74 @@ export function WeKnoraSourceDrawer({
|
||||
)}
|
||||
>
|
||||
<SheetTitle className="sr-only">{resolvedTitle}</SheetTitle>
|
||||
<SheetDescription className="sr-only">知识库文档详情</SheetDescription>
|
||||
<SheetDescription className="sr-only">知识库 Wiki 页面详情</SheetDescription>
|
||||
<div className="relative flex h-full min-h-0 flex-col bg-background">
|
||||
<div className="shrink-0 border-b px-5 py-4 pr-64">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-700">
|
||||
<FileText className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-base font-semibold text-foreground">{resolvedTitle}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline">{displayType(document, wikiPage)}</Badge>
|
||||
{formatDate(document?.created_at || wikiPage?.created_at) && (
|
||||
<span>{formatDate(document?.created_at || wikiPage?.created_at)}</span>
|
||||
)}
|
||||
{document?.parse_status && <span>{document.parse_status}</span>}
|
||||
<div className="shrink-0 border-b px-5 py-4 pr-12">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-64 flex-1 items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-700">
|
||||
{wikiPage ? <BookOpenText className="size-5" /> : <FileText className="size-5" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-base font-semibold text-foreground">{resolvedTitle}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline">{displayType(document, wikiPage)}</Badge>
|
||||
{formatDate(document?.created_at || wikiPage?.created_at) && (
|
||||
<span>{formatDate(document?.created_at || wikiPage?.created_at)}</span>
|
||||
)}
|
||||
{document?.parse_status && <span>{document.parse_status}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-4 right-12 flex items-center gap-2">
|
||||
{resolvedDocumentId && (
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => void downloadOriginal()} disabled={downloading}>
|
||||
{downloading ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||
下载原文
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{wikiPage && (
|
||||
<>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => void downloadWiki("md")} disabled={wikiDownload !== null}>
|
||||
{wikiDownload === "md" ? <Loader2 className="size-4 animate-spin" /> : <FileDown className="size-4" />}
|
||||
下载 MD
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => void downloadWiki("word")} disabled={wikiDownload !== null}>
|
||||
{wikiDownload === "word" ? <Loader2 className="size-4 animate-spin" /> : <FileText className="size-4" />}
|
||||
下载 Word
|
||||
</Button>
|
||||
{wikiWorkspacePath && (
|
||||
<Button type="button" size="sm" variant="outline" asChild>
|
||||
<Link
|
||||
to={wikiWorkspacePath}
|
||||
state={{ wikiPage }}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<BookOpenText className="size-4" />
|
||||
打开所在 Wiki
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{resolvedDocumentId && (
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => void downloadOriginal()} disabled={downloading}>
|
||||
{downloading ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||
下载原文
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setFullscreen((value) => !value)}
|
||||
aria-label={fullscreen ? "退出放大查看" : "单独放大查看文档"}
|
||||
>
|
||||
{fullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||||
{fullscreen ? "退出放大" : "放大查看"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setFullscreen((value) => !value)}
|
||||
aria-label={fullscreen ? "退出放大查看" : "单独放大查看文档"}
|
||||
>
|
||||
{fullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||||
{fullscreen ? "退出放大" : "放大查看"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||
正在加载完整文档预览
|
||||
正在加载完整 Wiki 文档
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -780,13 +912,16 @@ export function WeKnoraSourceDrawer({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{wikiContent && (
|
||||
{wikiDisplayContent && (
|
||||
<section className="border-b px-5 py-4">
|
||||
<div className="mb-2 text-sm font-medium text-foreground">Wiki 原文</div>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<BookOpenText className="size-4 text-emerald-600" />
|
||||
Wiki 文档正文
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background p-5">
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={wikiContent}
|
||||
content={wikiDisplayContent}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
|
||||
+49
-2
@@ -1,4 +1,4 @@
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, FilePlus2, FileText, Folder, Loader2, RefreshCw, Save, Search, Trash2 } from "lucide-react";
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, Cpu, Download, FilePlus2, FileText, Folder, Loader2, RefreshCw, Save, Search, Trash2 } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useLlmWikiActions,
|
||||
useLlmWikiKnowledgeBase,
|
||||
useLlmWikiRuntime,
|
||||
useLlmWikiWikiIndexStatus,
|
||||
useLlmWikiWikiPage,
|
||||
useLlmWikiWikiPages,
|
||||
} from "@/strategy-components/api/llmwiki";
|
||||
@@ -298,6 +299,7 @@ export function WeKnoraWikiManagementPage() {
|
||||
|
||||
const canWrite = !!details.data?.can_write && !details.data?.is_conversation_deposit;
|
||||
const pages = useLlmWikiWikiPages(noteId || null, 1, 500, "", !!noteId);
|
||||
const indexStatus = useLlmWikiWikiIndexStatus(noteId || null, !!noteId);
|
||||
const detail = useLlmWikiWikiPage(
|
||||
noteId || null,
|
||||
selectedSlug || null,
|
||||
@@ -561,6 +563,34 @@ export function WeKnoraWikiManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const vectorize = async () => {
|
||||
if (!canWrite) return;
|
||||
try {
|
||||
await actions.vectorizeWiki.mutateAsync({ id: noteId });
|
||||
toast.success("已提交整库 Wiki 向量化任务");
|
||||
await indexStatus.refetch();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "触发向量化失败");
|
||||
}
|
||||
};
|
||||
|
||||
const exportExcel = async () => {
|
||||
try {
|
||||
await actions.exportWikiExcel.mutateAsync({ id: noteId, name: title });
|
||||
toast.success("Wiki 已全部导出为 Excel");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "导出 Wiki Excel 失败");
|
||||
}
|
||||
};
|
||||
|
||||
const vectorStatusText = !indexStatus.data?.local_index_enabled
|
||||
? "本地向量未启用,检索使用 Wiki 接口"
|
||||
: indexStatus.data.fully_vectorized
|
||||
? `整库已向量化(${indexStatus.data.vector_count ?? 0} 个向量章节)`
|
||||
: indexStatus.data.state === "running" || indexStatus.data.state === "rebuilding"
|
||||
? "整库向量化进行中,完成前检索使用 Wiki 接口"
|
||||
: "整库尚未完成向量化,检索使用 Wiki 接口";
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
<header className="flex min-h-14 shrink-0 flex-wrap items-center justify-between gap-3 border-b px-4 py-2">
|
||||
@@ -571,9 +601,26 @@ export function WeKnoraWikiManagementPage() {
|
||||
</Button>
|
||||
<h1 className="truncate text-base font-semibold text-foreground">{title}</h1>
|
||||
</div>
|
||||
<p className="ml-9 mt-0.5 text-xs text-muted-foreground">Wiki 文档新增、编辑、删除</p>
|
||||
<p className="ml-9 mt-0.5 text-xs text-muted-foreground">Wiki 文档新增、编辑、删除 · {vectorStatusText}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void vectorize()}
|
||||
disabled={!canWrite || actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding"}
|
||||
>
|
||||
{actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding" ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<Cpu className="mr-2 size-4" />
|
||||
)}
|
||||
手动向量化
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => void exportExcel()} disabled={actions.exportWikiExcel.isPending}>
|
||||
{actions.exportWikiExcel.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Download className="mr-2 size-4" />}
|
||||
导出 Excel
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => void pages.refetch()}>
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
刷新
|
||||
|
||||
+479
@@ -0,0 +1,479 @@
|
||||
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 {
|
||||
buildPublicKnowledgeWikiPath,
|
||||
buildWeKnoraWikiPagePath,
|
||||
} from "@/core/llmwiki/source-links";
|
||||
import {
|
||||
buildWikiTree,
|
||||
filterWikiPagesWithAncestors,
|
||||
type WikiTreeNode,
|
||||
} from "@/core/llmwiki/wiki-tree";
|
||||
import { usePublicBackground } from "@/core/embed/background";
|
||||
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,
|
||||
usePublicLlmWikiKnowledgeBase,
|
||||
usePublicLlmWikiWikiPage,
|
||||
usePublicLlmWikiWikiPages,
|
||||
} 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);
|
||||
}
|
||||
|
||||
function selectLandingPage(pages: LlmWikiWikiPage[]): LlmWikiWikiPage | undefined {
|
||||
return (
|
||||
pages.find((page) => page.page_type?.toLowerCase() === "overview") ||
|
||||
pages.find((page) => /^(?:index|home|readme)$/i.test(page.slug)) ||
|
||||
pages.find((page) => !page.parent_slug && (page.depth ?? 0) === 0) ||
|
||||
pages[0]
|
||||
);
|
||||
}
|
||||
|
||||
export function WeKnoraWikiPageView({
|
||||
publicEmbed = false,
|
||||
}: {
|
||||
publicEmbed?: boolean;
|
||||
}) {
|
||||
const { noteId = "" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const publicBackground = usePublicBackground();
|
||||
const requestedWikiSlug = searchParams.get("wikiSlug")?.trim() || "";
|
||||
const privateKnowledgeBase = useLlmWikiKnowledgeBase(
|
||||
noteId || null,
|
||||
!!noteId && !publicEmbed,
|
||||
);
|
||||
const publicKnowledgeBase = usePublicLlmWikiKnowledgeBase(
|
||||
noteId || null,
|
||||
!!noteId && publicEmbed,
|
||||
);
|
||||
const knowledgeBase = publicEmbed ? publicKnowledgeBase : privateKnowledgeBase;
|
||||
const privatePages = useLlmWikiWikiPages(
|
||||
noteId || null,
|
||||
1,
|
||||
500,
|
||||
"",
|
||||
!!noteId && !publicEmbed,
|
||||
);
|
||||
const publicPages = usePublicLlmWikiWikiPages(
|
||||
noteId || null,
|
||||
1,
|
||||
500,
|
||||
"",
|
||||
!!noteId && publicEmbed,
|
||||
);
|
||||
const pages = publicEmbed ? publicPages : privatePages;
|
||||
const pagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
|
||||
const landingPage = useMemo(() => selectLandingPage(pagesList), [pagesList]);
|
||||
const wikiSlug = requestedWikiSlug || (publicEmbed ? landingPage?.slug || "" : "");
|
||||
const privatePage = useLlmWikiWikiPage(
|
||||
noteId || null,
|
||||
wikiSlug || null,
|
||||
!!noteId && !!wikiSlug && !publicEmbed,
|
||||
);
|
||||
const publicPage = usePublicLlmWikiWikiPage(
|
||||
noteId || null,
|
||||
wikiSlug || null,
|
||||
!!noteId && !!wikiSlug && publicEmbed,
|
||||
);
|
||||
const page = publicEmbed ? publicPage : privatePage;
|
||||
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 filteredPages = useMemo(
|
||||
() => filterWikiPagesWithAncestors(pagesList, directoryQuery),
|
||||
[directoryQuery, pagesList],
|
||||
);
|
||||
const wikiTree = useMemo(() => buildWikiTree(filteredPages), [filteredPages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!publicEmbed || requestedWikiSlug || !wikiSlug) return;
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("wikiSlug", wikiSlug);
|
||||
navigate(`${buildPublicKnowledgeWikiPath(noteId)}?${next.toString()}`, {
|
||||
replace: true,
|
||||
});
|
||||
}, [navigate, noteId, publicEmbed, requestedWikiSlug, searchParams, wikiSlug]);
|
||||
|
||||
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,
|
||||
publicEmbed
|
||||
? (knowledgeBaseId, slug) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("wikiSlug", slug);
|
||||
return `/#${buildPublicKnowledgeWikiPath(knowledgeBaseId)}?${next.toString()}`;
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
: "";
|
||||
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={
|
||||
publicEmbed
|
||||
? (() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("wikiSlug", node.page.slug);
|
||||
return `${buildPublicKnowledgeWikiPath(noteId)}?${next.toString()}`;
|
||||
})()
|
||||
: 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) {
|
||||
if (publicEmbed && pages.isLoading) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground">
|
||||
{publicEmbed ? "该知识库暂无可展示的 Wiki 页面" : "当前链接缺少 Wiki 页面标识"}
|
||||
{!publicEmbed && (
|
||||
<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
|
||||
: publicEmbed
|
||||
? "知识库未发布、页面不存在或已停止公开访问"
|
||||
: "页面不存在或当前账号无权访问"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!publicEmbed && (
|
||||
<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"
|
||||
style={publicEmbed ? publicBackground : undefined}
|
||||
data-public-wiki-embed={publicEmbed ? "true" : undefined}
|
||||
>
|
||||
{publicEmbed ? (
|
||||
<header className="flex min-h-14 shrink-0 items-center gap-3 border-b bg-background/95 px-4 py-2.5 shadow-sm sm:px-6">
|
||||
<div className="flex size-9 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-4.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="truncate text-sm font-semibold text-foreground">
|
||||
{knowledgeBase.data?.name || "知识库 Wiki"}
|
||||
</h1>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{title}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0 border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-300">
|
||||
公开 Wiki
|
||||
</Badge>
|
||||
</header>
|
||||
) : (
|
||||
<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;
|
||||
Reference in new issue
Block a user