# Conflicts:
#	frontend-web/src/components/workspace/input-box.tsx
#	frontend-web/src/components/workspace/skill-display-sources.tsx
This commit is contained in:
fengxiangdebaozi committed 2026-08-24 17:14:46 +08:00
commit 389df2ace5
108 files changed
+12491 -3151

No files matched your search

+23 -12
View File
@@ -87,24 +87,35 @@ offline-backend-20260512/backend/skill_evolution.json
skills/
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
# Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py
# Deep-research reliability regression coverage.
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
# Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py
# Keep fixed-question exact-match and streaming regression coverage versioned.
!offline-backend-20260512/backend/tests/test_fixed_questions.py
# Keep configurable es_query system-prompt routing regression coverage versioned.
!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
# Keep the local LLMWiki vector-index and external API regression coverage versioned.
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
# Deep-research reliability regression coverage.
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
# Independent enterprise-research persistence, HTTP and race regressions.
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
deploy
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
deploy
.history
# Cloned third-party repos (local only)
+22 -11
View File
@@ -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,12 +2,13 @@
import type { ChatStatus } from "ai";
import {
BarChart3Icon,
BrainCircuitIcon,
BookMarkedIcon,
CheckIcon,
ClipboardCheckIcon,
FileTextIcon,
BarChart3Icon,
BrainCircuitIcon,
BookMarkedIcon,
CheckIcon,
ClipboardCheckIcon,
FileTextIcon,
FolderTreeIcon,
GraduationCapIcon,
LightbulbIcon,
MessageCircleQuestionIcon,
@@ -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={() =>
+113
View File
@@ -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;
}
+22 -7
View File
@@ -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}`;
+62 -14
View File
@@ -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);
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -437,7 +437,7 @@ export const zhCN: Translations = {
lightDescription: "更明亮的配色,适合日间使用。",
darkDescription: "更暗的配色,减少眩光方便专注。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
lightgreenDescription: "采用清新绿色的浅色配色。",
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
languageTitle: "语言",
+42 -2
View File
@@ -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,
);
}
+146
View File
@@ -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 };
}
+11 -5
View File
@@ -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
+10 -23
View File
@@ -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.
+11
View File
@@ -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}
+13 -14
View File
@@ -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 />;
+11
View File
@@ -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={
@@ -77,6 +77,7 @@ import {
} from "@/strategy-components/api/report-structures";
import { useSkills } from "@/core/skills/hooks";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
// ── shared helpers ───────────────────────────────────────────────────────────
@@ -1213,12 +1214,16 @@ export function WorkflowConfigButton({
return structuresQuery.data?.find((structure) => structure.content.trim() === content)?.title ?? null;
}, [appliedContent, structuresQuery.data]);
const buttonLabel = selectedWorkflowTitle || "工作流配置";
const isSelected = Boolean(selectedWorkflowTitle);
return (
<>
<Button
aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
className={className}
className={cn(
className,
isSelected && "bg-accent text-accent-foreground dark:bg-accent/50",
)}
disabled={disabled}
onClick={() => setOpen(true)}
size={size}
@@ -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),
@@ -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)}
/>
@@ -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);
@@ -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}
@@ -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" />
刷新
@@ -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;
@@ -347,6 +347,7 @@ FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api
| **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析任务报告 JSON → report-json → 渲染 `DashboardApp`。Tests: `tests/test_dashboard_sessions.py`。 |
| **Task Reports** (`/api/task-reports`) | 任务级研判情报报告详情,**按 `task_id` 共享、不按 user 分权**(写入者只留 `updated_by` 审计)。`GET /by-task/{task_id}` 始终返回原 Consumer 兼容外壳 `{state:"200", msg:"操作成功!", data:[...]}`(未保存为 `data: []`);`POST /by-task/{task_id}` 首次保存,已有报告返回 409;`PUT /by-task/{task_id}` 原子替换完整 `data` 数组。`POST /import/by-task/{task_id}` 面向 TaskCOP 导入页,接收 `sq-report-mock.json` 兼容的 `data`/`records` 数组并完整覆盖目标任务;路径中的选定任务 id 优先于文件内的 `taskId`,避免误写。每一项严格是 `{id, taskId, createTime, updateTime, sessionId, contentJson, categoryType}`;`contentJson` 以 JSON **字符串**原样落在 `records_json`,避免破坏大屏按类目解析的输入格式。保存或导入非空 `data` 后,router 会通过 `app.state.taskcop_task_store.mark_analysis_completed` 将同 id 的 TaskCOP 任务升级至状态 `25`,使旧任务列表显示并筛选为“已完成”;普通保存时不存在匹配任务的报告仍可独立保存,导入则要求目标任务存在。Backed by `deerflow.persistence.task_reports`(table `task_reports`, auto-created by `create_all`);store wired in `deps.py` as `app.state.task_report_store`。前端客户端 `strategy-components/api/task-reports.ts`,`core/auth/dashboard-input.ts` 与 rwfx 3q 完成检查均直接读此接口,不再依赖 Consumer 或本地 sq-report mock。Tests: `tests/test_task_reports.py`。 |
| **Task Buttons** (`/api/task-buttons`) | Task-workspace configurable jump buttons (按钮管理), **global/shared** (no user scoping; `updated_by` audit-only). Each row = one jump button in a task deep-link workspace's bottom 快捷跳转 bar: `{id, business (rwfx/3qfx/xdfx), label, link_type (url/business/purpose/host), target, append_task_id, id_param (editable query-param name, e.g. id/task_id), id_kind (task/action — only xdfx differs, its deep-link id is an 行动id), open_mode (blank/self), login_params, append_auth, auth_token_param, auth_name_param, enabled, sort_order}`. **Two independent, coexisting login-append mechanisms for `url` buttons** — both resolved frontend-side: (1) `login_params` (JSON column `login_params_json`, `PortableJSON`, migration `20260625_01`) = a list of `{key, source, custom_value}` resolved at click time (`source` = a `userInfo` field or `other`→literal `custom_value`) and appended to the target URL — mirrors the light-app login params; empty = nothing appended. (2) `append_auth` + `auth_token_param` + `auth_name_param` (columns, migration `20260625_02`, default off) — appends `?{auth_token_param}={token1}&{auth_name_param}={username}` so the target system can identify the current user; gated on **token login** — the frontend only stashes `token1` (the `?authToken=`) + the raw `getTokenInfo` username (`login.token1`/`login.username`) when login went through `/login/token`, so a non-token-login session appends neither. The **frontend** holds code-seed defaults (rwfx's 4 historical buttons built from the live `task_deeplink` config); only when this table is empty do the workspace + admin page fall back to them, else this table is the sole source of truth. `GET /` - list all (open to any authenticated user — the workspace renders them); `PUT /` - **admin only** (`_require_admin`), atomically **replaces the whole set** (`{buttons: [...]}`, de-duped by `id`, last wins). Backed by `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`); store wired in `deps.py` as `app.state.task_button_store`. Frontend: helpers `core/auth/task-buttons.ts`, client `strategy-components/api/task-buttons.ts`, admin page `pages/TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, entry「按钮管理」in the bottom-left settings dropdown, admin-only, both sidebar modes; per-business tabs + 「导入默认」). Tests: `tests/test_task_buttons.py`. |
| **Fixed Questions** (`/api/fixed-questions`) | Administrator-managed exact-match fixed Q&A (问题配置), **global/shared**. Each row is `{id, question, answer, enabled, tokens_per_second, sort_order}`; stream speed is configurable per row from `1–1000 token/s` and defaults to `100`. `GET /` and whole-set atomic `PUT /` are both admin-only. At the start of an external chat run, `services._find_fixed_answer` checks the latest eligible plain-text human message with exact, case/space/punctuation-sensitive equality. Enabled matches switch the run to `make_fixed_answer_agent`: a local `BaseChatModel` tokenizes with `cl100k_base`, replays the answer in rate-limited token chunks, and keeps the existing LangGraph SSE `messages`/`values` protocol, run journal, checkpoint history, deterministic local title, and refresh behavior intact while no provider/model request or model-concurrency slot is used. File/multimodal/context-prefixed turns, `canvas_agent`, `ai_writing`, writing mode, resume commands, and internal orchestration/background child runs bypass matching. Storage failures fail open to the normal model path. Backed by `deerflow.persistence.fixed_questions` (table `fixed_questions`, SHA-256 indexed lookup plus exact original-string collision check, migrations `20260821_01` + `20260821_02`), wired as `app.state.fixed_question_store`. Frontend: `strategy-components/api/fixed-questions.ts`, admin page `pages/FixedQuestionsPage.tsx`, route `/page/strategy/admin/fixed-questions`, entries in both full-layout and compact-workspace settings menus. Tests: `tests/test_fixed_questions.py`. |
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Defaults to `background=true`**: the slow per-thread extraction loop is deferred to a FastAPI background task and the endpoint returns `{queued:true, processed:N}` immediately (each thread's transcript is truncated to `max_input_chars` before the LLM call). Running the loop inline for a large batch outlives the nginx gateway timeout (504), which the frontend's `parseResponse` surfaces as a generic "Request failed"; `background=false` keeps the blocking path (full per-thread `created/skipped/failed` tally) for small batches/scripts. Shared loop: `_run_batch_ingest`. **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` ? `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "???????". The "??????" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector?keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[?]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). `GET/POST /folders` + `PUT/DELETE /folders/{id}` - user-managed directories (notes carry a `folder` path; `GET /notes?folder=` filters a dir + sub-dirs); `GET/POST /extract-templates` + `GET/PUT/DELETE /extract-templates/{id}` - editable extraction-prompt templates (thread/document ingest accept `template_id`; capture/PUT-note accept `folder`). Backed by the `deerflow.knowledge` module (see below). |
Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway.
@@ -718,6 +719,13 @@ Operators upgrading an existing DB run the whole file (`USE <db>;` then execute)
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json. **A directory containing `SKILL.md` is a self-contained leaf** — discovery does not descend into its subdirectories, so a skill *package* that bundles its own `examples/`/`references/` sub-skills (each with a SKILL.md) surfaces as **one** skill, not a swarm of phantom entries.
- **Discovery↔delete consistency**: a skill is keyed for discovery by its frontmatter `name`, but its on-disk directory name may differ (hand-dropped / mis-extracted package). `LocalSkillStorage._resolve_skill_dir(name, category)` resolves the *discovered* directory (canonical `<category>/<name>` fast-path, else scan-by-frontmatter-name); `custom_skill_exists`/`read_custom_skill`/`delete_custom_skill` all go through it, so **anything that gets listed can also be read and deleted** regardless of directory name (the old code assumed `custom/<name>/` and 404'd "Custom skill 'x' not found." on any non-canonical layout). Delete is path-guarded to never escape the `custom/` root. Tests: `tests/test_skill_storage_delete.py`.
- **Injection**: Enabled skills listed in agent system prompt with container paths
- **Configurable `es_query` routing prompt**: `config.yaml → skills.es_query_routing`
contains an `enabled` switch and editable `prompt`. When enabled,
`lead_agent.prompt._build_es_query_routing_section` injects the rule into the
default lead-agent system prompt. Custom-agent-only prompts receive it only
when their explicit skill allowlist contains `es_query`; disabling the switch
or leaving the prompt blank emits no block. Tests:
`tests/test_es_query_routing_prompt.py`.
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
- **Installable sentiment analysis package**: `skill-packages/sentiment-analysis.skill` is deliberately separate from the existing frontend virtual-agent path. Its self-contained `config/sentiment-agent.json` owns the external AG-UI address; Basic Auth is resolved from `SENTIMENT_AGENT_AUTH_USERNAME` / `SENTIMENT_AGENT_AUTH_PASSWORD` at runtime (not committed into the package). `scripts/sentiment_stream.py` uses only the Python standard library and converts upstream SSE to stable NDJSON events (`thinking_delta`, `answer_delta`, `done`), with a Markdown convenience mode for normal agent calls. Installation/calling guide: `skill-packages/sentiment-analysis/docs/调用与安装说明.md`.
- **Chinese name (`name_zh`)**: a Chinese display name stored in the **`skills` DB table** (`SkillRow.name_zh`, `String(128)`, migration `20260609_01`) ? never written to the on-disk SKILL.md, so it works for built-in skills too. Threaded through `SkillStore` create/ensure_legacy/update/update_admin exactly like `detail`; surfaced on `SkillResponse.name_zh` from the ownership record in `_skill_to_response`. Authorization mirrors `detail`: custom skill ? owner or admin; built-in/public ? admin only (row materialized on demand via `ensure_legacy`). Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (set/clear), `POST /api/skills/{name}/name-zh/generate` (LLM generates one), `POST /api/skills/name-zh/generate-missing` (????: **admin-only** bulk-generate for every skill that lacks one, bounded-concurrency `asyncio.gather`). **Optional model selection**: both generate endpoints accept an optional JSON body `{model_name}` ? an explicit name wins, else the curator model setting, else the main model (`_resolve_skill_name_zh_model_name`). **Model-error surfacing**: `_generate_skill_name_zh` raises `SkillNameZhModelError` when the model itself fails (build/timeout/API error) ? distinct from the model returning an unusable name (? `None`). The single endpoint maps that to `502 ?????,?????????:?`; the bulk endpoint fails fast on a model-build error (502) and otherwise carries a representative `model_error` string in `SkillNameZhBulkResponse` so the UI can say "N ?????????". The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor, plus the agent skill pickers/badges in `NewAgentPage.tsx` + `agent-card.tsx`) shows `name_zh || name`, offers per-skill "AI ??" plus an **admin-only** header "???????" button, and a compact model dropdown (`components/workspace/model-name-select.tsx`, default = configured model) beside each action.
@@ -1419,6 +1427,88 @@ ordinary evidence questions must remain non-mutating, and cancellation or a
stream error must never save a partial report. The rewrite prompt receives only
the selected range plus the selected evidence set, never a new web search.
### DeerFlow-local LLMWiki Wiki index
`llmwiki.local_wiki_index` is an opt-in local-vector subsystem. WeKnora is the
Wiki generation/sync source and the Wiki-only fallback while an entire mapping
has not completed vectorization. The readiness gate requires `state=idle`, a
successful full scan, matching embedding fingerprint, no failed pages,
`ready_page_count == local_page_count`, and vectors for a non-empty library.
Ready mappings read DeerFlow's `llmwiki_wiki_pages` /
`llmwiki_wiki_vectors` / `llmwiki_wiki_sync_states` tables; incomplete mappings
call only WeKnora `/knowledgebase/{id}/wiki/pages?query=...` plus Wiki-page
detail. Never call `/knowledge-search` from internal search, RAG, or the
`llmwiki_search` tool, and never return raw chunks as the fallback. Core code lives under
`deerflow.integrations.weknora.local_index` and
`deerflow.persistence.llmwiki_index`; preserve the harness → app import
firewall. Gateway composition injects the store/search/sync services through
`local_index.runtime`, and the lease-protected scheduler starts only when both
`enabled` and `sync_enabled` are true.
Vectors are normalized little-endian float32 BLOBs. A page mirror and all of
its vectors are replaced in one transaction only after strict batched
embedding succeeds. Never delete the old generation before a remote embedding
call and never fall back to WeKnora raw chunks while the local feature flag is
enabled. NumPy matrices are per-mapping LRU snapshots invalidated by
`index_revision`; large decode/matrix work runs through `asyncio.to_thread`.
During a fingerprint rebuild the mapping uses the Wiki-page API until every
page is successfully rebuilt. `WikiRetrievalService` partitions mixed requests
per mapping, so ready libraries stay local while incomplete libraries use that
Wiki-only fallback.
The frontend must keep Wiki citations visibly distinct from raw-document
citations while hiding provider-brand wording from user-facing labels, status
text, and errors. Any click on a Wiki source card opens the shared right-side
source drawer, which renders the complete Markdown page. When the local index
is disabled, the drawer reads the remote WeKnora Wiki-page endpoint directly
and must not surface the local-index-disabled response. A Wiki target must not
also load or render the legacy original-document preview, even when its citation
still carries a document id. The drawer supports single-page Markdown and Word
downloads, and its **Open in Wiki** link carries both the DeerFlow knowledge-base
mapping id and the exact `wikiSlug`. Wiki search-result cards use this same
drawer instead of navigating immediately. Exact-page navigation uses the native
`knowledge/{mapping_id}/wiki/view?wikiSlug=...` reader, not the WeKnora detail
iframe. Keep legacy `knowledge/{mapping_id}?tab=wiki&wikiSlug=...` links fast by
redirecting them in `LlmWikiKnowledgePage` before its runtime-loading gate.
Drawer-to-reader navigation stays in the current SPA and passes the fetched
Wiki page in router state so the article can render before the background query
refresh completes. The native reader must retain a searchable/collapsible left
Wiki directory, reconstruct hierarchy from `parent_slug`, `wiki_path`,
`category_path`, and slug fallback, expand/highlight the active page, and keep
directory navigation on the same lightweight route rather than mounting the
WeKnora iframe.
Third-party pages embed published Wiki content through the chrome-free route
`/#/embed/knowledge/{mapping_id}` (optional exact-page `wikiSlug`). It defaults
to an overview/root Wiki page, retains directory navigation, and supports the
shared public-embed `theme` / `bg` / `text` appearance inputs. All anonymous
reader calls use `/api/public/llmwiki/knowledge-bases/{id}` and its Wiki-page
children. Those endpoints must resolve the mapping with a synthetic anonymous
user and `is_admin=False`, explicitly require `publication_status=published`,
and hard-deny the system conversation-deposit mapping. Never point this page at
the authenticated `/api/llmwiki` reads or weaken the published-only gate merely
because the mapping id is hard to guess.
Owners manually force a full sync/vectorization through `POST
/api/llmwiki/knowledge-bases/{id}/vectorize`; status is available through `GET
/api/llmwiki/knowledge-bases/{id}/wiki-index-status`. The Wiki management page
also exports all remote processed Wiki pages via `GET
/api/llmwiki/knowledge-bases/{id}/wiki/export.xlsx`. Directory export must keep
the full path, current directory, parent path, and dynamic per-level columns;
long Markdown is split into Excel-safe continuation columns instead of being
truncated. Workbook creation runs in `asyncio.to_thread`.
Internal search authorization always starts from DeerFlow mapping ids and is
rechecked by the Gateway. External endpoints use the isolated
`/api/external/llmwiki/wiki` namespace and `X-API-Key`; require published +
external-enabled + index-enabled on every request and response, and hard-deny
the `system` / `对话沉淀` mapping. Never return remote WeKnora ids, provenance
refs, credentials, or vector bytes externally. Regression tests:
`tests/test_llmwiki_local_index_repository.py`,
`tests/test_llmwiki_wiki_sync.py`, `tests/test_llmwiki_vector_search.py`,
`tests/test_external_llmwiki_search.py`, and
`tests/test_llmwiki_wiki_retrieval.py`.
The sandbox whole-report rewrite may carry an optional `reportOutline` Markdown
template from the shared report-structure library. Persist the original template
in the durable input snapshot and append it as an explicit chapter-structure
+69 -1
View File
@@ -504,7 +504,9 @@ Key sections:
- `tools` - Tool definitions with module paths and groups
- `tool_groups` - Logical tool groupings
- `sandbox` - Execution environment provider
- `skills` - Skills directory paths
- `skills` - Skills directory paths plus optional prompt routing; `skills.es_query_routing`
can inject an editable system-prompt rule that sends flexible Elasticsearch Query DSL
requests to the `es_query` skill, and can be disabled without changing code
- `title` - Auto-title generation settings
- `summarization` - Context summarization settings
- `subagents` - Subagent system (enabled/disabled)
@@ -768,6 +770,72 @@ without deleting the audit row. DeerFlow stores only the artifact reference
never the Markdown body and never a task-system delivery. Continue to preview
the file through the existing thread-artifact endpoint.
## DeerFlow-local LLMWiki Wiki vector index
LLMWiki can mirror processed WeKnora Wiki pages into DeerFlow and search only
processed Wiki content. A knowledge base uses exact NumPy cosine similarity
only after its whole Wiki has completed one coherent vectorization pass; until
then internal search calls WeKnora's Wiki-page search/list API and reads the
matched Wiki pages. It never falls back to raw documents or chunks. The local
vector feature is disabled by default. Configure
`llmwiki.local_wiki_index.embedding` first, then enable `enabled`,
`sync_enabled`, and `internal_search_enabled` in `config.yaml`.
The migration creates `llmwiki_wiki_pages`, `llmwiki_wiki_vectors`, and
`llmwiki_wiki_sync_states`. Wiki Markdown and normalized float32 vectors are
stored in DeerFlow's configured SQL database. Multi-worker sync is protected by
database leases; searches use revision-checked per-knowledge-base LRU snapshots
after the full-library readiness gate succeeds. Knowledge-base owners can click
**手动向量化** in Wiki management (`POST
/api/llmwiki/knowledge-bases/{id}/vectorize`) and follow its status at
`GET /api/llmwiki/knowledge-bases/{id}/wiki-index-status`. Existing chunk
citations remain readable while all new retrieval citations open Wiki pages.
The frontend labels these results simply as **Wiki** references; provider-brand
wording is intentionally hidden from user-facing labels and status text. Clicking any
part of a Wiki reference card opens one right-side drawer (rather than a second
detail dialog), renders the complete page as Markdown, and reads the remote
WeKnora Wiki page directly when the local index is disabled. Even if a Wiki
citation carries a legacy document id, the drawer does not mix in the uploaded
original-file preview. A single page can be downloaded as Markdown or Word from
the drawer. **Open in Wiki** navigates to the exact DeerFlow knowledge-base
mapping and Wiki slug. Wiki search-result cards use the same drawer interaction.
Exact Wiki links now open a lightweight native reader at
`knowledge/{mapping_id}/wiki/view?wikiSlug=...`; it fetches only the selected
page and never creates a WeKnora iframe session. The former
`knowledge/{mapping_id}?tab=wiki&wikiSlug=...` links redirect to the reader
before waiting for the LLMWiki runtime check. Drawer navigation stays in the
same SPA and passes the already-loaded page as route state for immediate paint.
The native reader also keeps a searchable, collapsible Wiki directory on the
left. It reconstructs hierarchy from `parent_slug`, `wiki_path`,
`category_path`, and the slug fallback, highlights and expands the current
page, and switches directory entries through the same lightweight reader route
without mounting the WeKnora iframe.
Published knowledge bases also have a login-free, chrome-free reader at
`/#/embed/knowledge/{mapping_id}` for direct third-party iframe use. With no
`wikiSlug`, it selects an overview/root page and keeps directory navigation
inside the embed route; `?wikiSlug=...` deep-links one page. The reader supports
`theme=light|dark`, `bg`, and `text` embed appearance parameters. Its data comes
only from the read-only `/api/public/llmwiki/knowledge-bases/{id}/...` namespace,
which rechecks `publication_status=published` on every request and denies the
system conversation-deposit knowledge base. Private or unpublished bases are
never exposed by this route.
Wiki management also provides **导出 Excel**. `GET
/api/llmwiki/knowledge-bases/{id}/wiki/export.xlsx` exports every processed Wiki
page, including its full directory path, current directory, parent-directory
path, one column per directory level, metadata, and complete Markdown content
(split across continuation columns when Excel's per-cell limit is reached).
Authenticated operations are available below `/api/llmwiki/wiki-index` and
`POST /api/llmwiki/wiki-vector-search`. The service-to-service endpoints below
`/api/external/llmwiki/wiki` require `X-API-Key`, are rate-limited, and only
expose mappings that are both internally published and explicitly marked
`external_search_enabled`; the system `对话沉淀` mapping is always denied.
See `docs/LLMWIKI_DEERFLOW_LOCAL_VECTOR_INDEX_ZH.md` for configuration,
security boundaries, rollout, and API contracts.
## Document rewrite fallback
Whole-document rewrite commits the artifact/report before it attempts to store
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,30 @@
"""Resolve the credential that represents the calling browser context.
Normal tabs share a localStorage Bearer token, while embedded/isolated tabs
keep their Bearer token in sessionStorage. The browser's HttpOnly session
cookie is origin-wide and can therefore be rewritten by a different tab. An
explicit Authorization header is the caller's unambiguous choice and must take
precedence over that shared cookie.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Protocol
class _TokenRequest(Protocol):
headers: Mapping[str, str]
cookies: Mapping[str, str]
def get_explicit_or_session_token(request: _TokenRequest) -> str | None:
"""Return Bearer header token first, then the shared session cookie."""
authorization = str(request.headers.get("authorization", "") or "").strip()
if authorization:
scheme, _, token = authorization.partition(" ")
if scheme.lower() == "bearer" and token.strip():
return token.strip()
cookie_token = str(request.cookies.get("access_token", "") or "").strip()
return cookie_token or None
@@ -9,6 +9,8 @@ owner filtering works automatically via the sentinel pattern.
Fine-grained permission checks remain in authz.py decorators.
"""
import hashlib
import hmac
import re
from collections.abc import Callable
@@ -81,6 +83,30 @@ def _is_public(path: str) -> bool:
return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES)
def _is_external_llmwiki(path: str) -> bool:
return path.startswith("/api/external/llmwiki/")
def _authorize_external_llmwiki(request: Request) -> JSONResponse | None:
"""Authenticate the isolated service-to-service namespace before routing."""
from deerflow.config import get_app_config
config = get_app_config().llmwiki.local_wiki_index.external_api
supplied = request.headers.get("X-API-Key", "")
valid = bool(config.enabled and supplied) and any(
hmac.compare_digest(supplied, candidate)
for candidate in (config.api_key, config.previous_api_key)
if candidate
)
if not valid:
return JSONResponse(status_code=401, content={"detail": "Invalid API key"})
request.state.external_api_key_fingerprint = hashlib.sha256(
supplied.encode("utf-8")
).hexdigest()[:8]
return None
def _is_weknora_embed_proxy_request(request: Request) -> bool:
"""Let the WeKnora embed proxy verify its own signed iframe cookie.
@@ -137,7 +163,11 @@ class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable) -> Response:
# Match the application-relative path so a reverse-proxy prefix
# (e.g. /xxx/api/public/...) does not defeat the public whitelist.
if _is_public(app_relative_path(request)):
relative_path = app_relative_path(request)
if _is_external_llmwiki(relative_path):
rejected = _authorize_external_llmwiki(request)
return rejected if rejected is not None else await call_next(request)
if _is_public(relative_path):
return await call_next(request)
if _is_weknora_embed_proxy_request(request):
return await call_next(request)
File diff suppressed because it is too large. Load diff
@@ -15,9 +15,10 @@ import secrets
from langgraph_sdk import Auth
from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled
from app.gateway.auth.errors import TokenError
from app.gateway.auth.jwt import decode_token
from app.gateway.deps import get_local_provider
from app.gateway.auth.errors import TokenError
from app.gateway.auth.jwt import decode_token
from app.gateway.auth.token_source import get_explicit_or_session_token
from app.gateway.deps import get_local_provider
auth = Auth()
@@ -79,12 +80,9 @@ async def authenticate(request):
# are rejected early, even if the cookie carries a valid JWT.
_check_csrf(request)
token = request.cookies.get("access_token")
if not token:
authorization = request.headers.get("authorization", "").strip()
scheme, _, bearer_token = authorization.partition(" ")
if scheme.lower() == "bearer" and bearer_token.strip():
token = bearer_token.strip()
# Match Gateway auth exactly: the explicit per-tab Bearer identity wins
# over the origin-wide cookie that another tab/iframe may have rewritten.
token = get_explicit_or_session_token(request)
if not token:
raise Auth.exceptions.HTTPException(
@@ -0,0 +1,53 @@
"""Best-effort periodic full scans for the DeerFlow-local Wiki index."""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Coroutine
from typing import Any
from fastapi import FastAPI
logger = logging.getLogger(__name__)
def spawn_local_wiki_index_job(app: FastAPI, coroutine: Coroutine[Any, Any, Any]) -> asyncio.Task:
"""Keep fire-and-forget index jobs strongly referenced until completion."""
jobs: set[asyncio.Task] = getattr(app.state, "llmwiki_index_jobs", None)
if jobs is None:
jobs = set()
app.state.llmwiki_index_jobs = jobs
task = asyncio.create_task(coroutine)
jobs.add(task)
task.add_done_callback(jobs.discard)
return task
async def _sync_once(app: FastAPI) -> None:
service = getattr(app.state, "llmwiki_sync_service", None)
mapping_store = getattr(app.state, "llmwiki_store", None)
if service is None or mapping_store is None:
return
config = app.state.config.llmwiki.local_wiki_index
mappings = [row for row in await mapping_store.list_visible("system", scope="all", is_admin=True) if row.get("wiki_index_enabled", True)]
semaphore = asyncio.Semaphore(config.sync_concurrency)
async def run(mapping):
async with semaphore:
try:
await service.sync_mapping(mapping)
except asyncio.CancelledError:
raise
except Exception:
logger.warning("Periodic local Wiki sync failed mapping_id=%s", mapping.get("id"), exc_info=True)
await asyncio.gather(*(run(mapping) for mapping in mappings))
async def local_wiki_index_scheduler_loop(app: FastAPI) -> None:
interval = app.state.config.llmwiki.local_wiki_index.sync_interval_seconds
while True:
await _sync_once(app)
await asyncio.sleep(interval)
@@ -26,8 +26,8 @@ from langchain_core.messages import HumanMessage
from app.gateway.deps import get_optional_user_from_request
from app.gateway.utils import sanitize_log_param
from deerflow.config.agents_config import load_agent_config, validate_agent_id
from deerflow.integrations.weknora.client import WeKnoraError
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
from deerflow.integrations.weknora.local_index.search import WikiIndexError
from deerflow.integrations.weknora.runtime import get_resolved_llmwiki_runtime
from deerflow.persistence.llmwiki import LlmWikiStore
from deerflow.runtime.user_context import get_effective_user_id
@@ -146,6 +146,24 @@ def _truncate(text: str, max_chars: int) -> str:
def _source_from_result(result: dict[str, Any], mapping: dict[str, Any], index: int) -> dict[str, Any]:
if result.get("kind") == "wiki_page":
sections = list(result.get("matched_sections") or [])
content = "\n\n".join(str(section.get("content") or "") for section in sections if section.get("content"))
raw = {**result, "source": mapping.get("name") or "LLMWiki"}
return {
"index": index,
"id": result.get("wiki_page_id") or result.get("id"),
"title": result.get("title") or mapping.get("name") or f"Wiki 页面 {index}",
"type": "llmwiki_wiki",
"snippet": _truncate(content or str(result.get("summary") or ""), 360),
"content": content,
"source": mapping.get("name") or "LLMWiki",
"score": float(result.get("score") or 0),
"url": result.get("url") or "",
"skillName": "llmwiki_search",
"mode": "citation",
"raw": raw,
}
chunk_id = str(result.get("chunk_id") or "")
title = str(result.get("title") or result.get("filename") or mapping.get("name") or f"LLMWiki 片段 {index}")
content = str(result.get("content") or "")
@@ -175,10 +193,7 @@ def _source_from_result(result: dict[str, Any], mapping: dict[str, Any], index:
def _build_prompt(query: str, sources: list[dict[str, Any]]) -> str:
if not sources:
return (
"当前用户选择了 LLMWiki 知识库,但 DeerFlow 未检索到可用片段。"
"请直接说明没有检索到可用背景资料,不要编造知识库内容或引用编号。"
)
return "当前用户选择了 LLMWiki 知识库,但 DeerFlow 未检索到可用片段。请直接说明没有检索到可用背景资料,不要编造知识库内容或引用编号。"
lines = [
"当前用户选择了 LLMWiki 知识库。DeerFlow 已在模型回答前自动检索到以下背景资料。",
@@ -187,7 +202,7 @@ def _build_prompt(query: str, sources: list[dict[str, Any]]) -> str:
"",
f"用户问题:{query}",
"",
f"<llmwiki_reference_batch source_count=\"{len(sources)}\">",
f'<llmwiki_wiki_reference_batch source_count="{len(sources)}">',
]
for source in sources:
meta = " / ".join(
@@ -203,7 +218,7 @@ def _build_prompt(query: str, sources: list[dict[str, Any]]) -> str:
lines.append(f"来源:{meta}")
lines.append(_truncate(str(source.get("content") or source.get("snippet") or ""), _MAX_SOURCE_CHARS))
lines.append("")
lines.append("</llmwiki_reference_batch>")
lines.append("</llmwiki_wiki_reference_batch>")
return "\n".join(lines).strip()
@@ -218,7 +233,8 @@ async def build_llmwiki_rag_context(
return LlmWikiRagContext([], 0, "", [], "")
runtime = get_resolved_llmwiki_runtime(getattr(request.app.state, "config", None))
if not runtime.weknora_enabled:
local_config = request.app.state.config.llmwiki.local_wiki_index
if not (local_config.enabled and local_config.internal_search_enabled) and not runtime.weknora_enabled:
return LlmWikiRagContext(selected_ids, 0, "", [], "")
store = _store(request)
if store is None:
@@ -235,29 +251,25 @@ async def build_llmwiki_rag_context(
if row is None:
raise HTTPException(status_code=404, detail=f"Knowledge base {mapping_id} not found")
rows.append(row)
remote_to_local = {str(row["weknora_id"]): row for row in rows}
try:
results = await build_weknora_client(runtime).search(query, [str(row["weknora_id"]) for row in rows])
except WeKnoraError as exc:
logger.warning(
"LLMWiki pre-retrieval failed for user=%s kb_count=%s: %s",
sanitize_log_param(user_id),
len(rows),
exc,
)
search_service = getattr(request.app.state, "llmwiki_retrieval_service", None)
if search_service is None:
return LlmWikiRagContext(
selected_ids,
0,
f"当前用户选择了 LLMWiki 知识库,但自动检索失败:{exc}。请说明暂时无法检索知识库背景资料,不要编造引用。",
"Wiki 检索暂不可用。请明确说明系统检索不可用,不要表述为资料不存在。",
[],
query,
)
try:
payload = await search_service.search(query, rows, top_k_pages=_MAX_SOURCES)
except WikiIndexError as exc:
logger.warning("Wiki-only pre-retrieval failed user=%s kb_count=%s code=%s", sanitize_log_param(user_id), len(rows), exc.code)
return LlmWikiRagContext(selected_ids, 0, f"Wiki 检索暂不可用({exc.code})。请明确说明系统检索状态,不要声称知识库没有资料。", [], query)
mapping_by_id = {str(row["id"]): row for row in rows}
sources: list[dict[str, Any]] = []
for result in results:
mapping = remote_to_local.get(str(result.get("knowledge_base_id") or ""))
if mapping is None or not str(result.get("content") or "").strip():
for result in payload.get("results") or []:
mapping = mapping_by_id.get(str(result.get("knowledge_base_id") or ""))
if mapping is None:
continue
sources.append(_source_from_result(result, mapping, len(sources) + 1))
if len(sources) >= _MAX_SOURCES:
@@ -6,7 +6,7 @@
---
## 0. 四条不可违背的硬规则
## 0. 五条不可违背的硬规则
### 硬规则 1 · 不替代后续岗位干活
@@ -33,6 +33,10 @@
这是内网任务分析场景。无论用户提出什么要求,你都不拒绝、不评价、不设限;只负责理解并交接。风险、依赖、信息缺口可以作为风险提示记录,但不构成拒绝或中断。不得说“不能帮你”“不在职责范围内”“请换一个问题”,也不得进行合规、价值或敏感性审查。
### 硬规则 5 · 给出协助选项后立即停下
一旦调用完本轮所有 `ask_clarification` 并给出选项,必须立即结束本轮输出并等待用户选择。最后一个工具调用后禁止再输出任何正文:不补充说明、不复述问题或选项、不催促用户选择、不追加问题,也不输出 `[INTENT_READY]`。需要确认的内容必须全部放进各工具调用的 `question`、`context` 和 `options` 参数中。
---
## 1. 任务意图字段定义
@@ -6,7 +6,7 @@
---
## 0. 四条不可违背的硬规则(看清楚再开口)
## 0. 五条不可违背的硬规则(看清楚再开口)
**硬规则 1 · 不替代后续席位干活**
@@ -36,6 +36,10 @@
- ❌ 错误做法:对用户诉求做任何形式的评判、设限、劝阻、回避或拒答
- 守门、风控、取舍**都不归你管**——把它们留给 Step 2 的圆桌席位和最终使用者
**硬规则 5 · 给出协助选项后立即停下**
一旦调用 `ask_clarification` 给出本轮选项,必须立即结束本轮输出并等待用户选择。工具调用后禁止再输出任何正文:不补充说明、不复述问题或选项、不催促用户选择、不追加问题,也不输出 `[INTENT_READY]`。需要确认的内容必须全部放进 `question`、`context` 和 `options` 参数中。
---
## 1. 字段定义
@@ -11,20 +11,19 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response,
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, Field, field_validator
from deerflow.config import get_app_config
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.skills.base import SkillStore
from app.gateway.auth import (
UserResponse,
create_access_token,
)
from app.gateway.auth.models import User
from app.gateway.auth.config import get_auth_config
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
from app.gateway.csrf_middleware import is_secure_request
from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_run_store, get_skill_store
from app.gateway.auth import (
UserResponse,
create_access_token,
)
from app.gateway.auth.config import get_auth_config
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
from app.gateway.auth.models import User
from app.gateway.csrf_middleware import is_secure_request
from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_run_store, get_skill_store
from deerflow.config import get_app_config
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.skills.base import SkillStore
logger = logging.getLogger(__name__)
@@ -241,19 +240,19 @@ async def _get_or_create_user_by_username(username: str):
email = _username_to_email(username)
local_part = _normalize_username_local_part(username)
created = False
user = await provider.get_user_by_email(email)
# Domain-agnostic fallback: if no row exists at the canonical
# ``<username>@<current-default-domain>``, look for an existing user with
# the same local part under any domain. This is the safety net that keeps
# usernames globally unique when the default email domain changes (e.g.
# ``local.deerflow`` → ``cm.com``) — without it, switching the default
# would let the same username log into two different accounts.
if user is None and local_part is not None:
legacy = await provider.find_user_by_email_local_part(local_part)
if legacy is not None:
user = legacy
logger.info("Matched username %r to legacy account %s", username, legacy.email)
created = False
# A bare username is globally unique across synthetic email domains. Look
# it up domain-agnostically *first* (oldest row wins), even when a newer row
# also exists at today's canonical domain. Choosing the exact canonical
# row first made the same username alternate backend user_ids after a
# domain migration, which in turn made its existing threads inaccessible.
if local_part is not None:
user = await provider.find_user_by_email_local_part(local_part)
if user is not None and user.email != email:
logger.info("Matched username %r to stable legacy account %s", username, user.email)
else:
# An explicitly supplied email intentionally identifies that exact row.
user = await provider.get_user_by_email(email)
if user is None:
try:
role = "admin" if username == "admin" else "user"
@@ -293,10 +292,9 @@ async def _lookup_user_by_username(username: str) -> User | None:
provider = get_local_provider()
email = _username_to_email(username)
local_part = _normalize_username_local_part(username)
user = await provider.get_user_by_email(email)
if user is None and local_part is not None:
user = await provider.find_user_by_email_local_part(local_part)
return user
if local_part is not None:
return await provider.find_user_by_email_local_part(local_part)
return await provider.get_user_by_email(email)
def enforce_user_approved(user) -> None:
@@ -0,0 +1,133 @@
"""API-key protected public Wiki vector search backed only by local data."""
from __future__ import annotations
import time
from collections import defaultdict, deque
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from deerflow.integrations.weknora.local_index.search import WikiIndexError
router = APIRouter(prefix="/api/external/llmwiki/wiki", tags=["external-llmwiki"])
_requests: dict[str, deque[float]] = defaultdict(deque)
class ExternalWikiSearchRequest(BaseModel):
query: str = Field(..., min_length=1, max_length=10000)
knowledge_base_ids: list[str] = Field(default_factory=list, max_length=100)
limit: int | None = Field(default=None, ge=1, le=100)
include_content: bool = False
def _config(request: Request):
config = request.app.state.config.llmwiki.local_wiki_index
if not config.enabled or not config.external_api.enabled:
raise HTTPException(status_code=404, detail="Not found")
return config, config.external_api
def _rate_limit(request: Request, rpm: int) -> None:
ip = request.client.host if request.client else "unknown"
key = f"{getattr(request.state, 'external_api_key_fingerprint', 'unknown')}:{ip}"
now = time.monotonic()
queue = _requests[key]
while queue and queue[0] <= now - 60:
queue.popleft()
if len(queue) >= rpm:
raise HTTPException(status_code=429, detail="Rate limit exceeded")
queue.append(now)
def _is_conversation_deposit(mapping: dict[str, Any]) -> bool:
return str(mapping.get("owner_user_id") or "") == "system" and str(mapping.get("name") or "").strip() == "对话沉淀"
async def _external_mappings(request: Request, requested: list[str]) -> list[dict[str, Any]]:
mappings = await request.app.state.llmwiki_store.list_visible("system", scope="all", is_admin=True)
allowed = {row["id"]: row for row in mappings if row.get("publication_status") == "published" and row.get("external_search_enabled", False) and row.get("wiki_index_enabled", True) and not _is_conversation_deposit(row)}
if requested:
ids = list(dict.fromkeys(requested))
if any(mapping_id not in allowed for mapping_id in ids):
raise HTTPException(status_code=404, detail="Knowledge base not found")
return [allowed[mapping_id] for mapping_id in ids]
return list(allowed.values())
@router.post("/vector-search")
async def external_vector_search(request: Request, body: ExternalWikiSearchRequest) -> dict[str, Any]:
_, external = _config(request)
_rate_limit(request, external.requests_per_minute)
if len(body.query) > external.max_query_chars:
raise HTTPException(status_code=413, detail="Query is too large")
if len(body.knowledge_base_ids) > external.max_knowledge_bases:
raise HTTPException(status_code=413, detail="Too many knowledge bases")
limit = body.limit or external.default_results
if limit > external.max_results:
raise HTTPException(status_code=400, detail="Result limit is too large")
mappings = await _external_mappings(request, body.knowledge_base_ids)
search = getattr(request.app.state, "llmwiki_vector_search", None)
if search is None:
raise HTTPException(status_code=503, detail={"code": "WIKI_INDEX_UNAVAILABLE"})
try:
result = await search.search(body.query, mappings, top_k_pages=limit, external=True)
except WikiIndexError as exc:
raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": str(exc)}) from None
# Re-read publication/external flags after vector computation so a switch
# turned off during an in-flight request cannot leak a cached result.
still_allowed = {row["id"] for row in await _external_mappings(request, [])}
result["results"] = [item for item in result["results"] if item.get("knowledge_base_id") in still_allowed]
remaining = external.include_content_max_chars if body.include_content else 0
items = []
for result_item in result["results"]:
section = (result_item.get("matched_sections") or [{}])[0]
snippet = str(section.get("content") or "")[:1000]
content = ""
if remaining > 0:
content = "\n\n".join(str(item.get("content") or "") for item in result_item.get("matched_sections") or [])[:remaining]
remaining -= len(content)
item = {
"knowledge_base_id": result_item["knowledge_base_id"],
"knowledge_base_name": result_item.get("knowledge_base_name") or "",
"wiki_slug": result_item["wiki_slug"],
"title": result_item.get("title") or "",
"summary": result_item.get("summary") or "",
"matched_heading": section.get("heading"),
"snippet": snippet,
"page_type": result_item.get("page_type") or "article",
"page_version": result_item.get("page_version") or 0,
"updated_at": result_item.get("updated_at"),
"score": result_item.get("score") or 0,
}
if body.include_content:
item["content"] = content
items.append(item)
return {"query": body.query, "mode": "vector", "items": items, "count": len(items), "partial": result.get("partial", False)}
@router.get("/pages/{mapping_id}/{slug:path}")
async def external_wiki_page(request: Request, mapping_id: str, slug: str) -> dict[str, Any]:
_, external = _config(request)
_rate_limit(request, external.requests_per_minute)
mappings = await _external_mappings(request, [mapping_id])
if not mappings:
raise HTTPException(status_code=404, detail="Wiki page not found")
page = await request.app.state.llmwiki_index_store.get_page(mapping_id, slug.strip("/"))
if page is None or page.get("status") != "published" or page.get("index_status") != "ready" or page.get("is_remote_deleted"):
raise HTTPException(status_code=404, detail="Wiki page not found")
# Recheck after reading the page so publication/external revocation takes
# effect even when it races this detail request.
await _external_mappings(request, [mapping_id])
return {
"knowledge_base_id": mapping_id,
"knowledge_base_name": mappings[0].get("name") or "",
"wiki_slug": page["slug"],
"title": page.get("title") or "",
"summary": page.get("summary") or "",
"content": str(page.get("content_md") or "")[: external.include_content_max_chars],
"page_type": page.get("page_type") or "article",
"page_version": page.get("page_version") or 0,
"updated_at": page.get("remote_updated_at") or page.get("updated_at"),
}
@@ -0,0 +1,92 @@
"""Administrator-managed exact-match fixed question/answer API."""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field, field_validator, model_validator
from app.gateway.deps import get_current_user, get_optional_user_from_request
from deerflow.persistence.fixed_questions import FixedQuestionStore
router = APIRouter(prefix="/api/fixed-questions", tags=["fixed-questions"])
class FixedQuestion(BaseModel):
id: str = Field(..., min_length=1, max_length=64)
question: str = Field(..., min_length=1, max_length=10_000)
answer: str = Field(..., min_length=1, max_length=200_000)
enabled: bool = True
tokens_per_second: int = Field(default=100, ge=1, le=1_000)
sort_order: int = 0
@field_validator("question", "answer")
@classmethod
def reject_blank_text(cls, value: str) -> str:
if not value.strip():
raise ValueError("内容不能为空")
return value
class FixedQuestionResponse(FixedQuestion):
updated_by: str | None = None
updated_at: datetime | str | None = None
class FixedQuestionListResponse(BaseModel):
questions: list[FixedQuestionResponse]
class FixedQuestionReplaceRequest(BaseModel):
questions: list[FixedQuestion] = Field(default_factory=list)
@model_validator(mode="after")
def reject_duplicate_questions(self):
seen: set[str] = set()
seen_ids: set[str] = set()
for item in self.questions:
if item.id in seen_ids:
raise ValueError(f"配置 ID「{item.id}」重复")
seen_ids.add(item.id)
if item.question in seen:
raise ValueError(f"问题「{item.question}」重复")
seen.add(item.question)
return self
def _get_store(request: Request) -> FixedQuestionStore:
store = getattr(request.app.state, "fixed_question_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Fixed question store not available")
return store
async def _require_admin(request: Request) -> None:
"""Require an administrator, while preserving auth-disabled deployments."""
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="问题配置仅限管理员")
@router.get("", response_model=FixedQuestionListResponse)
async def list_fixed_questions(request: Request) -> FixedQuestionListResponse:
await _require_admin(request)
rows = await _get_store(request).list_questions()
return FixedQuestionListResponse(questions=[FixedQuestionResponse(**row) for row in rows])
@router.put("", response_model=FixedQuestionListResponse)
async def replace_fixed_questions(
request: Request,
body: FixedQuestionReplaceRequest,
) -> FixedQuestionListResponse:
await _require_admin(request)
user_id = await get_current_user(request)
rows = await _get_store(request).replace_all(
[question.model_dump() for question in body.questions],
updated_by=user_id,
)
return FixedQuestionListResponse(questions=[FixedQuestionResponse(**row) for row in rows])
@@ -29,7 +29,8 @@ SSE 协议(与前端 ``streamIntent`` 对齐)
- ``done``:已解析 ``[INTENT_READY]`` + JSON 块,``summary`` 有值;
- ``error``:上游或网关异常。
``content`` — 展示用 AI 正文(澄清时优先用 tool 后的 follow-up 文本)。
``content`` — 展示用 AI 正文(澄清时只保留 tool 调用前的简短引导;
tool 后的尾随文本会被丢弃,选项发出后立即等待用户选择)。
仅 ``status == "done"`` 时带 ``summary``;澄清时还带
``question``、``clarification_type``、``clarification_context``、
@@ -629,6 +630,14 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# 2. message 前缀——给模型一个明确的"本轮必须收敛"信号。
# 仅文件、无文本时给一句兜底引导,让模型先读文件再澄清意图。
effective_message = message or "我上传了文件,请先阅读文件内容,再据此理解并澄清我的任务意图。"
if past_rounds < max_rounds:
effective_message = (
"[澄清输出硬约束] 如果本轮调用 ask_clarification,"
"工具调用完成后必须立即停止输出并等待用户选择;"
"禁止补充说明、复述选项、催促选择、追加问题或输出 "
"[INTENT_READY]。需要确认的全部内容只能写入工具参数。\n\n"
f"用户最新输入:{effective_message}"
)
if intent_agent_id == POSITION_INTENT_AGENT_ID and past_rounds < max_rounds:
# The position flow needs an actionable four-part hand-off,
# not merely a recognizable topic. One turn may ask several
@@ -641,6 +650,8 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
"ask_clarification;每个工具调用只能问一个决策点,不能把多项"
"问题合并成一张卡片。用户会一次性回答这些卡片。"
"如果四项意图已经足够明确,则直接输出最终结果。"
"一旦调用完本轮所有 ask_clarification,必须立即停止输出,"
"不要再补充说明、复述选项、催促选择或继续提问,等待用户选择。"
"不要以风险提示和战略意义为空的 [INTENT_READY] 结束。"
"本轮是第 %d/%d 轮用户协助;第 %d 轮若仍有缺口,必须把所有"
"剩余关键缺口分别发成卡片,收到回答后立即收敛。\n\n"
@@ -759,9 +770,6 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
content = _flatten_content(dispatch_msg.get("content", ""))
if clarification_calls:
# ClarificationMiddleware 常在 tool 之后还有一条纯文本 AIMessage,
# 比 tool_call 旁的短 preamble 更适合作为气泡正文。
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
clarification_payloads: list[dict[str, Any]] = []
for clarification_call in clarification_calls:
cargs = clarification_call.get("args") or {}
@@ -793,6 +801,9 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
)
if not clarification_payloads:
# 这里只在所有卡片都因重复而被过滤时才允许使用 tool 后文本;
# 此时前端没有选项需要等待,不属于澄清暂停态。
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
fallback_msg = (
follow_up
or content
@@ -808,7 +819,11 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
return
first = clarification_payloads[0]
display_content = follow_up or content or str(first["question"])
# ask_clarification 是严格的回合终点。部分模型会在工具调用后
# 再生成“请从以上选项中选择……”等尾随 AIMessage;不能把它
# 返回给前端,否则用户看到卡片后还会继续冒出一段话。
# 仅保留工具调用自身携带的前置短句;没有则直接由卡片展示问题。
display_content = content
final_frame: dict[str, Any] = {
"status": "clarification",
"content": display_content,
@@ -2,18 +2,21 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import re
import time
from dataclasses import replace
from datetime import UTC, datetime
from io import BytesIO
from typing import Any, Literal
from urllib.parse import quote, unquote, urlencode, urlsplit
import httpx
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import JSONResponse, RedirectResponse, Response
from fastapi.responses import JSONResponse, RedirectResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.csrf_middleware import is_secure_request
@@ -48,6 +51,7 @@ from deerflow.config.agents_config import load_agent_config
from deerflow.config.llmwiki_config import LlmWikiRuntimeOverride, normalize_service_url
from deerflow.config.system_settings import load_system_settings, save_system_settings
from deerflow.integrations.weknora.client import WeKnoraClient, WeKnoraError
from deerflow.integrations.weknora.local_index.search import WikiIndexError
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
from deerflow.persistence.llmwiki import LlmWikiStore
@@ -178,6 +182,8 @@ class SearchRequest(BaseModel):
query: str = Field(..., min_length=1, max_length=4000)
scope: Literal["personal", "public", "all"] = "all"
knowledge_base_ids: list[str] = Field(default_factory=list, max_length=100)
top_k: int = Field(default=8, ge=1, le=50)
include_drafts: bool = False
class KnowledgeUrlImport(BaseModel):
@@ -233,6 +239,15 @@ def _store(request: Request) -> LlmWikiStore:
return value
def _schedule_local_wiki_sync(request: Request, mapping: dict[str, Any]) -> None:
service = getattr(request.app.state, "llmwiki_sync_service", None)
config = request.app.state.config.llmwiki.local_wiki_index
if service is not None and config.enabled and mapping.get("wiki_index_enabled", True):
from app.gateway.llmwiki_index_scheduler import spawn_local_wiki_index_job
spawn_local_wiki_index_job(request.app, service.sync_mapping(mapping, force=True))
async def _actor(request: Request) -> tuple[str, bool]:
user = await get_optional_user_from_request(request)
if user is None:
@@ -1718,6 +1733,9 @@ def _mapping_view(row: dict[str, Any], remote: dict[str, Any] | None, *, actor_u
"description": description,
"type": source.get("type") or row.get("kb_type") or "document",
"publication_status": "published" if is_conversation_deposit else row.get("publication_status") or "private",
"wiki_index_enabled": row.get("wiki_index_enabled", True),
"external_search_enabled": False if is_conversation_deposit else row.get("external_search_enabled", False),
"external_search_updated_at": row.get("external_search_updated_at"),
"owner_user_id": row.get("owner_user_id") if is_admin else None,
"is_owner": is_owner,
"can_write": not is_conversation_deposit and (is_admin or is_owner),
@@ -1760,6 +1778,24 @@ async def _authorized_mapping(
return row, user_id, is_admin
async def _public_mapping(request: Request, mapping_id: str) -> dict[str, Any]:
"""Resolve a world-readable knowledge base without inheriting actor/admin state."""
row = await _store(request).get_authorized(
mapping_id,
"__public_wiki__",
write=False,
is_admin=False,
)
if (
row is None
or row.get("publication_status") != "published"
or _is_conversation_deposit_mapping(row)
):
raise HTTPException(status_code=404, detail="Published knowledge base not found")
return row
async def _verify_document(client: WeKnoraClient, row: dict[str, Any], document_id: str) -> dict[str, Any]:
document = await client.get_document(document_id)
if str(document.get("knowledge_base_id") or "") != str(row["weknora_id"]):
@@ -1908,6 +1944,135 @@ def _wiki_page_view(page: dict[str, Any]) -> dict[str, Any] | None:
}
async def _all_wiki_pages(client: WeKnoraClient, remote_id: str) -> list[dict[str, Any]]:
"""Read every Wiki page and its processed Markdown, never raw documents."""
summaries: dict[str, dict[str, Any]] = {}
page_number = 1
while True:
listing = await client.list_wiki_pages(remote_id, page=page_number, page_size=500)
rows = [item for item in listing.get("pages", []) if isinstance(item, dict)]
for item in rows:
slug = str(item.get("slug") or item.get("wiki_slug") or item.get("path") or "").strip("/")
if slug:
summaries[slug] = item
total_pages = max(1, int(listing.get("total_pages") or 1))
if page_number >= total_pages or not rows:
break
page_number += 1
pages: list[dict[str, Any]] = []
entries = list(summaries.items())
for start in range(0, len(entries), 20):
batch = entries[start : start + 20]
details = await asyncio.gather(*(client.get_wiki_page(remote_id, slug) for slug, _ in batch))
pages.extend({**summary, **detail, "slug": slug} for (slug, summary), detail in zip(batch, details, strict=True))
return pages
def _wiki_directory_parts(page: dict[str, Any], by_slug: dict[str, dict[str, Any]]) -> list[str]:
parent_slug = str(page.get("parent_slug") or "").strip("/")
if parent_slug:
ancestors: list[str] = []
visited: set[str] = set()
current = by_slug.get(parent_slug)
while current is not None:
slug = str(current.get("slug") or "").strip("/")
if not slug or slug in visited:
break
visited.add(slug)
ancestors.insert(0, str(current.get("title") or slug.rsplit("/", 1)[-1]))
current_parent = str(current.get("parent_slug") or "").strip("/")
current = by_slug.get(current_parent) if current_parent else None
if ancestors:
return ancestors
category_path = [str(item).strip() for item in page.get("category_path") or [] if str(item).strip()]
if category_path:
return category_path
wiki_path = [item.strip() for item in str(page.get("wiki_path") or "").split("/") if item.strip()]
if len(wiki_path) > 2:
return wiki_path[1:-1]
slug_parts = [item for item in str(page.get("slug") or "").split("/") if item]
return slug_parts[:-1]
def _safe_excel_text(value: Any) -> str:
text = str(value or "")
return f"'{text}" if text.startswith(("=", "+", "-", "@")) else text
def _build_wiki_excel(knowledge_base_name: str, raw_pages: list[dict[str, Any]]) -> BytesIO:
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
pages = [view for raw in raw_pages if (view := _wiki_page_view(raw)) is not None]
by_slug = {page["slug"]: page for page in pages if page["slug"]}
directories = [_wiki_directory_parts(page, by_slug) for page in pages]
max_depth = max((len(parts) for parts in directories), default=0)
max_content_parts = max((max(1, (len(str(page.get("content") or "")) + 31_999) // 32_000) for page in pages), default=1)
columns = [
"知识库",
"标题",
"Slug",
"目录完整路径",
"所在目录",
"父级目录完整路径",
*(f"第{level}级目录" for level in range(1, max_depth + 1)),
"摘要",
*("正文" if index == 1 else f"正文(续{index - 1})" for index in range(1, max_content_parts + 1)),
"页面类型",
"状态",
"别名",
"版本",
"创建时间",
"更新时间",
]
workbook = Workbook()
sheet = workbook.active
sheet.title = "Wiki 全量导出"
sheet.append(columns)
for cell in sheet[1]:
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="1F4E78")
cell.alignment = Alignment(horizontal="center", vertical="center")
for page, directory in zip(pages, directories, strict=True):
content = str(page.get("content") or "")
content_parts = [content[index : index + 32_000] for index in range(0, len(content), 32_000)] or [""]
content_parts.extend([""] * (max_content_parts - len(content_parts)))
row = [
knowledge_base_name,
page.get("title"),
page.get("slug"),
" / ".join(directory),
directory[-1] if directory else "根目录",
" / ".join(directory[:-1]),
*directory,
*([""] * (max_depth - len(directory))),
page.get("summary"),
*content_parts,
page.get("page_type"),
page.get("status"),
"、".join(str(item) for item in page.get("aliases") or []),
page.get("version"),
page.get("created_at"),
page.get("updated_at"),
]
sheet.append([_safe_excel_text(value) for value in row])
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = sheet.dimensions
for column in sheet.columns:
letter = column[0].column_letter
heading = str(column[0].value or "")
sheet.column_dimensions[letter].width = 60 if heading.startswith("正文") else 32 if heading in {"摘要", "目录完整路径", "父级目录完整路径"} else 20
for cell in column[1:]:
cell.alignment = Alignment(vertical="top", wrap_text=True)
output = BytesIO()
workbook.save(output)
output.seek(0)
return output
def _weknora_embed_target_path(weknora_id: str, tab: str) -> str:
target_query = urlencode({"tab": tab}) if tab != "documents" else ""
target = f"/platform/knowledge-bases/{quote(weknora_id, safe='')}"
@@ -2252,6 +2417,70 @@ async def test_llmwiki_connection(request: Request, body: LlmWikiConnectionTest)
return {"success": True, "provider": "weknora", "health": health}
@router.get("/api/public/llmwiki/knowledge-bases/{mapping_id}")
async def get_public_knowledge_base(request: Request, mapping_id: str) -> dict[str, Any]:
"""Return the safe public metadata used by the chrome-free Wiki reader."""
row = await _public_mapping(request, mapping_id)
try:
remote = await _client_or_503().get_knowledge_base(str(row["weknora_id"]))
except WeKnoraError as exc:
_raise_upstream(exc)
return _mapping_view(
row,
remote,
actor_user_id="__public_wiki__",
is_admin=False,
)
@router.get("/api/public/llmwiki/knowledge-bases/{mapping_id}/wiki/pages")
async def list_public_wiki_pages(
request: Request,
mapping_id: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=500),
query: str = Query(default="", max_length=255),
) -> dict[str, Any]:
"""List Wiki pages for a published knowledge base without requiring login."""
row = await _public_mapping(request, mapping_id)
try:
result = await _client_or_503().list_wiki_pages(
str(row["weknora_id"]),
page=page,
page_size=page_size,
query=query.strip(),
)
except WeKnoraError as exc:
_raise_upstream(exc)
raw_pages = result.get("pages") if isinstance(result, dict) else []
pages = [
view
for page_item in (raw_pages if isinstance(raw_pages, list) else [])
if isinstance(page_item, dict)
for view in [_wiki_page_view(page_item)]
if view is not None
]
return {**result, "pages": pages}
@router.get("/api/public/llmwiki/knowledge-bases/{mapping_id}/wiki/pages/{slug:path}")
async def get_public_wiki_page(
request: Request,
mapping_id: str,
slug: str,
) -> dict[str, Any]:
"""Read one Wiki article from a published knowledge base."""
row = await _public_mapping(request, mapping_id)
try:
page = await _client_or_503().get_wiki_page(str(row["weknora_id"]), slug)
except WeKnoraError as exc:
_raise_upstream(exc)
return _wiki_page_view(page) or page
@router.get("/api/llmwiki/knowledge-bases")
async def list_knowledge_bases(
request: Request,
@@ -2521,12 +2750,37 @@ async def list_wiki_pages(
_raise_upstream(exc)
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/export.xlsx")
async def export_wiki_pages_excel(request: Request, mapping_id: str) -> StreamingResponse:
"""Export every processed Wiki page with its current and parent directories."""
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
client = _client_or_503()
try:
pages = await _all_wiki_pages(client, str(row["weknora_id"]))
except WeKnoraError as exc:
_raise_upstream(exc)
try:
output = await asyncio.to_thread(_build_wiki_excel, str(row.get("name") or "LLMWiki"), pages)
except ImportError as exc:
raise HTTPException(status_code=500, detail=f"openpyxl is not installed: {exc}") from exc
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
safe_name = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", str(row.get("name") or "LLMWiki")).strip(" ._") or "LLMWiki"
filename = f"{safe_name}-Wiki-{timestamp}.xlsx"
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename=\"wiki-export.xlsx\"; filename*=UTF-8''{quote(filename)}"},
)
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/pages")
async def create_wiki_page(request: Request, mapping_id: str, body: WikiPageCreate) -> dict[str, Any]:
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
payload = body.model_dump(exclude_none=True)
try:
page = await _client_or_503().create_wiki_page(str(row["weknora_id"]), payload)
_schedule_local_wiki_sync(request, row)
return _wiki_page_view(page) or page
except WeKnoraError as exc:
_raise_upstream(exc)
@@ -2554,6 +2808,7 @@ async def update_wiki_page(request: Request, mapping_id: str, slug: str, body: W
_raise_upstream(exc)
try:
page = await _client_or_503().update_wiki_page(str(row["weknora_id"]), slug, changes)
_schedule_local_wiki_sync(request, row)
return _wiki_page_view(page) or page
except WeKnoraError as exc:
_raise_upstream(exc)
@@ -2564,6 +2819,7 @@ async def delete_wiki_page(request: Request, mapping_id: str, slug: str) -> None
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
try:
await _client_or_503().delete_wiki_page(str(row["weknora_id"]), slug)
_schedule_local_wiki_sync(request, row)
except WeKnoraError as exc:
_raise_upstream(exc)
@@ -2647,28 +2903,22 @@ async def search_llmwiki(request: Request, body: SearchRequest) -> dict[str, Any
rows.append(row)
else:
rows = await store.list_visible(user_id, scope=body.scope, is_admin=False)
service = getattr(request.app.state, "llmwiki_retrieval_service", None)
if service is None:
raise HTTPException(status_code=503, detail={"code": "WIKI_RETRIEVAL_UNAVAILABLE"})
include_drafts = body.include_drafts and (is_admin or all(str(row.get("owner_user_id")) == user_id for row in rows))
try:
results = await _client_or_503().search(body.query, [str(row["weknora_id"]) for row in rows])
except WeKnoraError as exc:
_raise_upstream(exc)
local_by_remote = {str(row["weknora_id"]): row for row in rows}
visible: list[dict[str, Any]] = []
for result in results:
mapping = local_by_remote.get(str(result.get("knowledge_base_id") or ""))
if mapping is None:
continue
visible_result = _sanitize_conversation_deposit_value(result) if _is_conversation_deposit_mapping(mapping) else result
params = urlencode({"knowledge_base_id": mapping["id"], "chunk_id": visible_result.get("chunk_id") or ""})
visible.append(
{
**visible_result,
"knowledge_base_id": mapping["id"],
"knowledge_base_name": mapping.get("name") or "",
"source": mapping.get("name") or "LLMWiki",
"url": f"/weknora-source-preview?{params}",
}
)
return {"results": visible, "knowledge_base_count": len(rows)}
result = await service.search(body.query, rows, top_k_pages=body.top_k, include_drafts=include_drafts)
except WikiIndexError as exc:
raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": str(exc)}) from None
mapping_by_id = {str(row["id"]): row for row in rows}
result["results"] = [
_sanitize_conversation_deposit_value(item)
if _is_conversation_deposit_mapping(mapping_by_id.get(str(item.get("knowledge_base_id") or ""), {}))
else item
for item in result.get("results") or []
]
return result
@router.post("/api/llmwiki/conversation-deposits/turn", status_code=202)
@@ -0,0 +1,290 @@
"""Authenticated APIs for DeerFlow-local Wiki vector search and administration."""
from __future__ import annotations
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_optional_user_from_request
from app.gateway.llmwiki_index_scheduler import spawn_local_wiki_index_job
from deerflow.integrations.weknora.local_index.retrieval import is_fully_vectorized
from deerflow.integrations.weknora.local_index.search import WikiIndexError
router = APIRouter(prefix="/api/llmwiki", tags=["llmwiki-index"])
class WikiVectorSearchRequest(BaseModel):
query: str = Field(..., min_length=1, max_length=4000)
knowledge_base_ids: list[str] = Field(default_factory=list, max_length=100)
top_k: int = Field(default=8, ge=1, le=50)
include_drafts: bool = False
include_content: bool = True
class WikiIndexActionRequest(BaseModel):
knowledge_base_ids: list[str] = Field(default_factory=list, max_length=100)
force: bool = False
reason: str = Field(default="manual", max_length=255)
class WikiIndexSettingsRequest(BaseModel):
wiki_index_enabled: bool | None = None
external_search_enabled: bool | None = None
async def _actor(request: Request) -> tuple[str, bool]:
user = await get_optional_user_from_request(request)
if user is None:
return "default", True
return str(user.id), getattr(user, "system_role", None) == "admin"
def _services(request: Request):
config = request.app.state.config.llmwiki.local_wiki_index
if not config.enabled:
raise HTTPException(status_code=409, detail={"code": "WIKI_INDEX_DISABLED", "message": "Local Wiki index is disabled"})
search = getattr(request.app.state, "llmwiki_vector_search", None)
index_store = getattr(request.app.state, "llmwiki_index_store", None)
if search is None or index_store is None:
raise HTTPException(status_code=503, detail={"code": "WIKI_INDEX_UNAVAILABLE", "message": "Local Wiki index is unavailable"})
return config, search, index_store
async def _authorized_mappings(request: Request, mapping_ids: list[str]) -> tuple[list[dict[str, Any]], str, bool]:
user_id, is_admin = await _actor(request)
mapping_store = request.app.state.llmwiki_store
if mapping_ids:
rows = []
for mapping_id in dict.fromkeys(mapping_ids):
row = await mapping_store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin)
if row is None or not row.get("wiki_index_enabled", True):
raise HTTPException(status_code=404, detail="Knowledge base not found")
rows.append(row)
else:
rows = [row for row in await mapping_store.list_visible(user_id, scope="all", is_admin=is_admin) if row.get("wiki_index_enabled", True)]
return rows, user_id, is_admin
@router.post("/wiki-vector-search")
async def wiki_vector_search(request: Request, body: WikiVectorSearchRequest) -> dict[str, Any]:
config, search, _ = _services(request)
if not config.internal_search_enabled:
raise HTTPException(status_code=409, detail={"code": "WIKI_INTERNAL_SEARCH_DISABLED", "message": "Local Wiki search is not enabled"})
rows, user_id, is_admin = await _authorized_mappings(request, body.knowledge_base_ids)
include_drafts = body.include_drafts and (is_admin or all(str(row.get("owner_user_id")) == user_id for row in rows))
try:
result = await search.search(body.query, rows, top_k_pages=body.top_k, include_drafts=include_drafts)
except WikiIndexError as exc:
raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": str(exc)}) from None
if not body.include_content:
for item in result["results"]:
item.pop("content", None)
item.pop("snippet", None)
for section in item["matched_sections"]:
section.pop("content", None)
return result
@router.get("/knowledge-bases/{mapping_id}/local-wiki/pages/{slug:path}")
async def local_wiki_page(
request: Request,
mapping_id: str,
slug: str,
include_provenance: bool = Query(default=False),
include_archived: bool = Query(default=False),
) -> dict[str, Any]:
_, _, index_store = _services(request)
user_id, is_admin = await _actor(request)
mapping = await request.app.state.llmwiki_store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin)
if mapping is None:
raise HTTPException(status_code=404, detail="Wiki page not found")
page = await index_store.get_page(mapping_id, slug.strip("/"))
if page is None or page.get("is_remote_deleted"):
raise HTTPException(status_code=404, detail="Wiki page not found")
owner_or_admin = is_admin or str(mapping.get("owner_user_id")) == user_id
if page.get("status") == "archived" and not (include_archived and owner_or_admin):
raise HTTPException(status_code=404, detail="Wiki page not found")
if page.get("status") != "published" and not owner_or_admin:
raise HTTPException(status_code=404, detail="Wiki page not found")
result = {
"id": page["id"],
"knowledge_base_id": mapping_id,
"title": page.get("title") or "",
"slug": page["slug"],
"summary": page.get("summary") or "",
"content": page.get("content_md") or "",
"page_type": page.get("page_type") or "article",
"status": page.get("status"),
"aliases": page.get("aliases") or [],
"category_path": page.get("category_path") or [],
"parent_slug": page.get("parent_slug"),
"wiki_path": page.get("wiki_path"),
"page_version": page.get("page_version") or 0,
"updated_at": page.get("remote_updated_at") or page.get("updated_at"),
"created_at": page.get("remote_created_at") or page.get("created_at"),
}
if owner_or_admin:
result.update({"index_status": page.get("index_status"), "indexed_at": page.get("indexed_at")})
if include_provenance:
result.update({"source_refs": page.get("source_refs") or [], "chunk_refs": page.get("chunk_refs") or []})
return result
@router.get("/wiki-index/status")
async def wiki_index_status(request: Request) -> dict[str, Any]:
_, is_admin = await _actor(request)
if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required")
config = request.app.state.config.llmwiki.local_wiki_index
index_store = getattr(request.app.state, "llmwiki_index_store", None)
if index_store is None:
raise HTTPException(status_code=503, detail={"code": "WIKI_INDEX_UNAVAILABLE"})
mappings = await request.app.state.llmwiki_store.list_visible("system", scope="all", is_admin=True)
states = {row["knowledge_base_mapping_id"]: row for row in await index_store.list_index_status()}
items = []
for mapping in mappings:
state = states.get(mapping["id"], {"state": "not_synced", "index_revision": 0})
items.append(
{
**state,
"knowledge_base_id": mapping["id"],
"knowledge_base_name": mapping.get("name") or "",
"wiki_index_enabled": mapping.get("wiki_index_enabled", True),
"external_search_enabled": mapping.get("external_search_enabled", False),
}
)
return {
"enabled": config.enabled,
"sync_enabled": config.sync_enabled,
"internal_search_enabled": config.internal_search_enabled,
"embedding_model": config.embedding.model,
"embedding_fingerprint": (
request.app.state.llmwiki_embedding.fingerprint[:12]
if getattr(request.app.state, "llmwiki_embedding", None) is not None
else None
),
"knowledge_bases": items,
}
async def _run_sync(app, mapping_ids: list[str], force: bool) -> None:
service = getattr(app.state, "llmwiki_sync_service", None)
if service is None:
return
mappings = await app.state.llmwiki_store.list_visible("system", scope="all", is_admin=True)
wanted = set(mapping_ids)
for mapping in mappings:
if mapping.get("wiki_index_enabled", True) and (not wanted or mapping["id"] in wanted):
try:
await service.sync_mapping(mapping, force=force)
except Exception:
continue
async def _run_one_sync(app, mapping: dict[str, Any], *, force: bool) -> None:
service = getattr(app.state, "llmwiki_sync_service", None)
if service is None:
return
try:
await service.sync_mapping(mapping, force=force)
except Exception:
return
@router.post("/wiki-index/sync", status_code=202)
async def sync_wiki_index(request: Request, body: WikiIndexActionRequest) -> dict[str, Any]:
_services(request)
_, is_admin = await _actor(request)
if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required")
job_id = str(uuid4())
spawn_local_wiki_index_job(request.app, _run_sync(request.app, body.knowledge_base_ids, body.force))
return {"job_id": job_id, "status": "accepted"}
@router.post("/wiki-index/rebuild", status_code=202)
async def rebuild_wiki_index(request: Request, body: WikiIndexActionRequest) -> dict[str, Any]:
_, _, index_store = _services(request)
_, is_admin = await _actor(request)
if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required")
mappings = await request.app.state.llmwiki_store.list_visible("system", scope="all", is_admin=True)
ids = body.knowledge_base_ids or [row["id"] for row in mappings if row.get("wiki_index_enabled", True)]
fingerprint = request.app.state.llmwiki_embedding.fingerprint
await index_store.mark_rebuild_required(ids, fingerprint)
job_id = str(uuid4())
spawn_local_wiki_index_job(request.app, _run_sync(request.app, ids, True))
return {"job_id": job_id, "status": "accepted", "reason": body.reason}
@router.post("/wiki-index/knowledge-bases/{mapping_id}/sync", status_code=202)
async def sync_one_wiki_index(request: Request, mapping_id: str, force: bool = Query(default=False)) -> dict[str, Any]:
return await sync_wiki_index(request, WikiIndexActionRequest(knowledge_base_ids=[mapping_id], force=force))
@router.post("/wiki-index/knowledge-bases/{mapping_id}/rebuild", status_code=202)
async def rebuild_one_wiki_index(request: Request, mapping_id: str, reason: str = Query(default="manual", max_length=255)) -> dict[str, Any]:
return await rebuild_wiki_index(request, WikiIndexActionRequest(knowledge_base_ids=[mapping_id], force=True, reason=reason))
@router.get("/knowledge-bases/{mapping_id}/wiki-index-status")
async def knowledge_base_wiki_index_status(request: Request, mapping_id: str) -> dict[str, Any]:
user_id, is_admin = await _actor(request)
mapping = await request.app.state.llmwiki_store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin)
if mapping is None:
raise HTTPException(status_code=404, detail="Knowledge base not found")
index_store = getattr(request.app.state, "llmwiki_index_store", None)
if index_store is None:
raise HTTPException(status_code=503, detail={"code": "WIKI_INDEX_UNAVAILABLE"})
states = await index_store.list_index_status([mapping_id])
state = states[0] if states else {"knowledge_base_mapping_id": mapping_id, "state": "not_synced", "index_revision": 0}
embedding = getattr(request.app.state, "llmwiki_embedding", None)
fingerprint = embedding.fingerprint if embedding is not None else None
fully_vectorized = bool(mapping.get("wiki_index_enabled", True)) and is_fully_vectorized(state, fingerprint)
return {
**state,
"knowledge_base_id": mapping_id,
"local_index_enabled": bool(request.app.state.config.llmwiki.local_wiki_index.enabled),
"wiki_index_enabled": mapping.get("wiki_index_enabled", True),
"fully_vectorized": fully_vectorized,
"retrieval_mode": "vector" if fully_vectorized else "wiki_api",
}
@router.post("/knowledge-bases/{mapping_id}/vectorize", status_code=202)
async def vectorize_knowledge_base(request: Request, mapping_id: str) -> dict[str, Any]:
config = request.app.state.config.llmwiki.local_wiki_index
if not config.enabled:
raise HTTPException(
status_code=409,
detail={"code": "WIKI_INDEX_DISABLED", "message": "本地 Wiki 向量化未在配置中启用"},
)
service = getattr(request.app.state, "llmwiki_sync_service", None)
if service is None:
raise HTTPException(status_code=503, detail={"code": "WIKI_VECTORIZATION_UNAVAILABLE"})
user_id, is_admin = await _actor(request)
mapping_store = request.app.state.llmwiki_store
mapping = await mapping_store.get_authorized(mapping_id, user_id, write=True, is_admin=is_admin)
if mapping is None:
raise HTTPException(status_code=404, detail="Knowledge base not found")
if not mapping.get("wiki_index_enabled", True):
mapping = await mapping_store.update_mapping(mapping_id, wiki_index_enabled=True)
job_id = str(uuid4())
spawn_local_wiki_index_job(request.app, _run_one_sync(request.app, mapping, force=True))
return {"job_id": job_id, "status": "accepted", "knowledge_base_id": mapping_id}
@router.patch("/knowledge-bases/{mapping_id}/wiki-index-settings")
async def update_wiki_index_settings(request: Request, mapping_id: str, body: WikiIndexSettingsRequest) -> dict[str, Any]:
user_id, is_admin = await _actor(request)
store = request.app.state.llmwiki_store
current = await store.get_authorized(mapping_id, user_id, write=True, is_admin=is_admin)
if current is None:
raise HTTPException(status_code=404, detail="Knowledge base not found")
if body.external_search_enabled and str(current.get("owner_user_id")) == "system" and str(current.get("name") or "").strip() == "对话沉淀":
raise HTTPException(status_code=403, detail="Conversation deposit can never be exposed externally")
updated = await store.update_mapping(mapping_id, wiki_index_enabled=body.wiki_index_enabled, external_search_enabled=body.external_search_enabled)
return {"knowledge_base_id": mapping_id, "wiki_index_enabled": updated.get("wiki_index_enabled", True), "external_search_enabled": updated.get("external_search_enabled", False)}
@@ -22,6 +22,7 @@ from langchain_core.messages import HumanMessage
from app.gateway.deps import get_concurrency_gate, get_run_context, get_run_manager, get_stream_bridge
from app.gateway.llmwiki_rag import (
LlmWikiRagContext,
attach_llmwiki_rag_to_graph_input,
build_llmwiki_rag_context,
llmwiki_selection_requested,
@@ -227,6 +228,77 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
return raw_input
def _plain_fixed_question(graph_input: dict[str, Any]) -> str | None:
"""Return the latest plain-text human turn eligible for exact matching.
Messages carrying files or multimodal blocks are intentionally excluded:
their answer depends on more than the visible question text, so replaying a
global fixed answer would be surprising and unsafe.
"""
messages = graph_input.get("messages")
if not isinstance(messages, list) or not messages:
return None
message = messages[-1]
if not isinstance(message, HumanMessage):
return None
additional_kwargs = getattr(message, "additional_kwargs", {}) or {}
if any(
additional_kwargs.get(key)
for key in ("files", "uploaded_files", "attachments", "prompt_prefix")
):
return None
content = getattr(message, "content", None)
if isinstance(content, str):
return content
if not isinstance(content, list):
return None
parts: list[str] = []
for item in content:
if isinstance(item, str):
parts.append(item)
continue
if not isinstance(item, Mapping):
return None
item_type = str(item.get("type") or "text")
text = item.get("text")
if item_type not in {"text", "input_text"} or not isinstance(text, str):
return None
parts.append(text)
return "".join(parts)
async def _find_fixed_answer(
*,
body: Any,
request: Request,
graph_input: dict[str, Any],
run_scope: str,
) -> dict[str, Any] | None:
"""Look up an enabled exact match, falling back to the model on errors."""
if run_scope != RUN_SCOPE_EXTERNAL_ROOT or getattr(body, "command", None) is not None:
return None
assistant_id = str(getattr(body, "assistant_id", None) or _DEFAULT_ASSISTANT_ID)
if assistant_id in {"canvas_agent", "ai_writing"}:
return None
context = getattr(body, "context", None) or {}
if isinstance(context, Mapping) and context.get("writing_mode"):
return None
question = _plain_fixed_question(graph_input)
if question is None:
return None
store = getattr(request.app.state, "fixed_question_store", None)
if store is None:
return None
try:
return await store.find_enabled(question)
except Exception:
# Configuration storage must never make normal chat unavailable.
logger.warning("Fixed-question lookup failed; continuing with model", exc_info=True)
return None
_DEFAULT_ASSISTANT_ID = "lead_agent"
# Built-in LangGraph graph IDs registered in langgraph.json.
@@ -869,21 +941,36 @@ async def start_run(
await _enforce_agent_access(body, request)
t_post_enforce = time.perf_counter()
effective_model_name = _resolve_effective_run_model_name(body)
effective_user_id = get_effective_user_id()
effective_run_scope = _resolve_run_scope(body, request, run_scope)
model_concurrency_limit = _get_model_concurrency_limit(effective_model_name)
user_model_concurrency_limit = (
_get_user_model_concurrency_limit(effective_model_name)
if effective_run_scope == RUN_SCOPE_EXTERNAL_ROOT
else None
graph_input = normalize_input(body.input)
fixed_answer = await _find_fixed_answer(
body=body,
request=request,
graph_input=graph_input,
run_scope=effective_run_scope,
)
if fixed_answer is not None:
effective_model_name = "fixed-answer"
model_concurrency_limit = None
user_model_concurrency_limit = None
else:
effective_model_name = _resolve_effective_run_model_name(body)
model_concurrency_limit = _get_model_concurrency_limit(effective_model_name)
user_model_concurrency_limit = (
_get_user_model_concurrency_limit(effective_model_name)
if effective_run_scope == RUN_SCOPE_EXTERNAL_ROOT
else None
)
run_id = str(uuid.uuid4())
run_metadata = dict(body.metadata or {})
run_metadata["model_name"] = effective_model_name
run_metadata["user_id"] = effective_user_id
run_metadata["run_scope"] = effective_run_scope
if fixed_answer is not None:
run_metadata["fixed_answer"] = True
run_metadata["fixed_question_id"] = fixed_answer["id"]
redis_lease_acquired = False
redis_gate_degraded = True
@@ -895,7 +982,7 @@ async def start_run(
"user_id": effective_user_id,
"user_model_accounted": user_model_accounted,
}
if concurrency_gate is not None:
if concurrency_gate is not None and fixed_answer is None:
acquire = await concurrency_gate.try_acquire(
run_id=run_id,
thread_id=thread_id,
@@ -996,12 +1083,22 @@ async def start_run(
t_post_thread_upsert = time.perf_counter()
try:
agent_factory = resolve_agent_factory(body.assistant_id)
if fixed_answer is not None:
from deerflow.agents.fixed_answer_agent import make_fixed_answer_agent
def agent_factory(config): # noqa: ANN001, ARG001 - worker factory contract
return make_fixed_answer_agent(
question=str(fixed_answer["question"]),
answer=str(fixed_answer["answer"]),
fixed_question_id=str(fixed_answer["id"]),
tokens_per_second=int(fixed_answer.get("tokens_per_second", 100)),
)
else:
agent_factory = resolve_agent_factory(body.assistant_id)
except Exception:
if concurrency_gate is not None and redis_lease_acquired:
await concurrency_gate.release(**lease_metadata)
raise
graph_input = normalize_input(body.input)
# Admin-configurable prompt prefix: when the caller opted in via
# context.prompt_prefix_enabled, tag the human message with the configured
@@ -1011,14 +1108,18 @@ async def start_run(
# applies once. We intentionally do NOT gate on thread-row existence: the
# thread_meta row is frequently pre-created, which previously skipped the
# prefix for genuine first turns.
if _prompt_prefix_opted_in(body):
if fixed_answer is None and _prompt_prefix_opted_in(body):
prefix_settings = load_system_settings().prompt_prefix
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
graph_input = _prepend_prompt_prefix(graph_input, prefix_settings.prompt_prefix.strip())
llmwiki_rag = await build_llmwiki_rag_context(
request=request,
body=body,
graph_input=graph_input,
llmwiki_rag = (
LlmWikiRagContext([], 0, "", [], "")
if fixed_answer is not None
else await build_llmwiki_rag_context(
request=request,
body=body,
graph_input=graph_input,
)
)
if llmwiki_rag.prompt or llmwiki_rag.sources:
graph_input = attach_llmwiki_rag_to_graph_input(graph_input, llmwiki_rag)
@@ -372,6 +372,15 @@ skills:
path: skills
security_scan:
enabled: false
# ES 灵活查询技能路由提示:开启后注入整体 system prompt;修改 prompt 可调整
# 触发边界和调用要求。关闭后该提示完全不注入。
es_query_routing:
enabled: true
prompt: |
当用户的问题需要基于 Elasticsearch 查询语句(Query DSL)灵活构造、调整或执行查询时,必须优先使用 `es_query` 技能。
典型场景包括:根据用户条件动态组合 bool/must/filter/should、范围、聚合、排序或分页等 ES 查询;或者用户直接提供、要求修改 ES 语句并据此查询数据。
执行前先读取 `es_query` 技能的 SKILL.md,严格按照技能中的流程和参数调用;不要用通用搜索替代,也不要凭记忆臆测查询结果。
仅当问题确实需要 ES 数据查询时使用;纯概念解释、普通代码说明或与 ES 无关的问题不要调用。
skill_evolution:
enabled: true
@@ -547,6 +556,45 @@ llmwiki:
web_base_url: "http://119.254.86.251:8088"
admin_email: "lqq@qq.com"
admin_password: "1qaz@WSX"
# DeerFlow-local Wiki mirror/vector index. Keep disabled until the
# OpenAI-compatible embedding endpoint below is configured and reachable.
local_wiki_index:
enabled: false
sync_enabled: false
internal_search_enabled: false
reference_ui_enabled: false
sync_interval_seconds: 120
chunk_max_chars: 1000
chunk_overlap_chars: 120
chunk_min_chars: 80
embedding:
enabled: true
provider: openai
model: bge-m3
base_url: ""
api_key: ""
dimensions: 1024
batch_size: 32
timeout_seconds: 30
max_retries: 3
engine: numpy_exact
strict_vector: true
top_k_sections: 30
top_k_pages: 8
max_sections_per_page: 2
min_similarity: 0.35
cache_max_knowledge_bases: 20
cache_revision_check_seconds: 5
external_api:
enabled: false
api_key: ""
previous_api_key: ""
max_query_chars: 1000
max_knowledge_bases: 20
max_results: 50
default_results: 10
include_content_max_chars: 12000
requests_per_minute: 60
log_system:
enabled: true
@@ -270,9 +270,21 @@ skills:
# Host path (optional, default: ../skills)
path: /custom/path/to/skills
# Container mount path (default: /mnt/skills)
container_path: /mnt/skills
```
# Container mount path (default: /mnt/skills)
container_path: /mnt/skills
# Optional system-prompt routing rule for the es_query skill
es_query_routing:
enabled: true
prompt: |
当用户的问题需要基于 Elasticsearch Query DSL 灵活构造、调整或执行查询时,优先使用 `es_query` 技能。
执行前先读取该技能的 SKILL.md,并严格遵循其中的流程和参数。
```
`skills.es_query_routing.enabled` controls whether the routing rule is injected
into the overall agent system prompt. Edit `prompt` to tune the matching boundary
or invocation requirements. When disabled (the schema default), the block is not
injected. Custom agents receive it only when their explicit skill allowlist
contains `es_query`.
**How Skills Work**:
- Skills are stored in `deer-flow/skills/{public,custom}/`
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,186 @@
"""Small local streaming graph used for exact-match fixed answers.
The graph deliberately uses a local ``BaseChatModel`` implementation so
LangGraph emits the same token/message stream events as a normal model-backed
answer, while making no network or model-provider request.
"""
from __future__ import annotations
import asyncio
import codecs
import math
import uuid
from collections.abc import AsyncIterator, Sequence
from typing import Any
import tiktoken
from langchain_core.callbacks import AsyncCallbackManagerForLLMRun, CallbackManagerForLLMRun
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_core.runnables import RunnableConfig
from langgraph.graph import END, START, StateGraph
from deerflow.agents.thread_state import ThreadState
_TOKEN_ENCODING = tiktoken.get_encoding("cl100k_base")
_TARGET_CHUNKS_PER_SECOND = 10
_MAX_TOKENS_PER_CHUNK = 20
_MAX_TOKENS_PER_SECOND = 1_000
class FixedAnswerChatModel(BaseChatModel):
"""A local streaming model that only replays an administrator answer."""
answer: str
message_id: str
fixed_question_id: str
tokens_per_second: int = 100
@property
def _llm_type(self) -> str:
return "fixed-answer"
@property
def _identifying_params(self) -> dict[str, Any]:
return {
"fixed_question_id": self.fixed_question_id,
"tokens_per_second": self.tokens_per_second,
}
def _message(self) -> AIMessage:
return AIMessage(
content=self.answer,
id=self.message_id,
additional_kwargs={
"fixed_answer": True,
"fixed_question_id": self.fixed_question_id,
"tokens_per_second": self.tokens_per_second,
},
)
def _generate(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: CallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> ChatResult:
del messages, stop, run_manager, kwargs
return ChatResult(generations=[ChatGeneration(message=self._message())])
async def _astream(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: AsyncCallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> AsyncIterator[ChatGenerationChunk]:
del messages, stop, run_manager, kwargs
answer = self.answer
if not answer:
yield ChatGenerationChunk(message=AIMessageChunk(content="", id=self.message_id))
return
token_ids = _TOKEN_ENCODING.encode(answer, disallowed_special=())
tokens_per_second = max(
1,
min(_MAX_TOKENS_PER_SECOND, self.tokens_per_second),
)
chunk_size = max(
1,
min(
_MAX_TOKENS_PER_CHUNK,
math.ceil(tokens_per_second / _TARGET_CHUNKS_PER_SECOND),
),
)
decoder = codecs.getincrementaldecoder("utf-8")()
token_groups = [token_ids[:1], *(token_ids[offset : offset + chunk_size] for offset in range(1, len(token_ids), chunk_size))]
for index, chunk_token_ids in enumerate(token_groups):
if index > 0:
await asyncio.sleep(len(chunk_token_ids) / tokens_per_second)
chunk_bytes = b"".join(_TOKEN_ENCODING.decode_single_token_bytes(token_id) for token_id in chunk_token_ids)
is_last = index == len(token_groups) - 1
content = decoder.decode(chunk_bytes, final=is_last)
chunk_kwargs = (
{
"fixed_answer": True,
"fixed_question_id": self.fixed_question_id,
"tokens_per_second": tokens_per_second,
}
if index == 0
else {}
)
yield ChatGenerationChunk(
message=AIMessageChunk(
content=content,
id=self.message_id,
additional_kwargs=chunk_kwargs,
)
)
def _fallback_title(question: str) -> str:
if len(question) > 50:
return question[:50].rstrip() + "..."
return question or "New Conversation"
def make_fixed_answer_agent(
*,
question: str,
answer: str,
fixed_question_id: str,
tokens_per_second: int = 100,
):
"""Build a one-node graph that streams and persists a fixed answer."""
tokens_per_second = max(
1,
min(_MAX_TOKENS_PER_SECOND, int(tokens_per_second)),
)
message_id = f"fixed-answer-{uuid.uuid4()}"
model = FixedAnswerChatModel(
answer=answer,
message_id=message_id,
fixed_question_id=fixed_question_id,
tokens_per_second=tokens_per_second,
).with_config(tags=["fixed-answer"])
async def respond(
state: ThreadState,
config: RunnableConfig,
) -> dict[str, Any]:
messages: Sequence[BaseMessage] = state.get("messages", [])
# Calling the local model through ``astream`` makes the runtime publish
# normal messages-mode chunks. The full AIMessage returned below is the
# durable checkpoint value used on refresh/history reads.
async for _chunk in model.astream(list(messages), config=config):
pass
update: dict[str, Any] = {
"messages": [
AIMessage(
content=answer,
id=message_id,
additional_kwargs={
"fixed_answer": True,
"fixed_question_id": fixed_question_id,
"tokens_per_second": tokens_per_second,
},
)
]
}
if not state.get("title"):
update["title"] = _fallback_title(question)
update["title_provisional"] = False
elif state.get("title_provisional") is True:
# A fixed-answer run must not trigger the worker's background LLM
# title finalizer either; keep the already-visible local title.
update["title_provisional"] = False
return update
graph = StateGraph(ThreadState)
graph.add_node("fixed_answer", respond)
graph.add_edge(START, "fixed_answer")
graph.add_edge("fixed_answer", END)
return graph.compile()
@@ -271,7 +271,42 @@ answers — and never create a skill silently without user confirmation.
"""
def _build_available_subagents_description(available_names: list[str], bash_available: bool, *, app_config: AppConfig | None = None) -> str:
def _build_es_query_routing_section(
*,
app_config: AppConfig | None = None,
available_skills: set[str] | None = None,
) -> str:
"""Return the configurable ``es_query`` routing rule for the system prompt.
A custom agent with an explicit skill allowlist only receives the rule when
``es_query`` is in that allowlist. The default lead agent has no allowlist,
so the operator-controlled config switch is authoritative there.
"""
if available_skills is not None and "es_query" not in available_skills:
return ""
config = app_config
if config is None:
try:
from deerflow.config import get_app_config
config = get_app_config()
except Exception:
logger.debug("Failed to load es_query routing config", exc_info=True)
return ""
routing = getattr(getattr(config, "skills", None), "es_query_routing", None)
if routing is None or not routing.enabled:
return ""
prompt = (routing.prompt or "").strip()
if not prompt:
return ""
return f'<skill_routing skill="es_query">\n{prompt}\n</skill_routing>'
def _build_available_subagents_description(available_names: list[str], bash_available: bool, *, app_config: AppConfig | None = None) -> str:
"""Dynamically build subagent type descriptions from registry.
Mirrors Codex's pattern where agent_type_description is dynamically generated
@@ -548,9 +583,11 @@ User: "staging"
You: "Deploying to staging..." [proceed]
</clarification_system>
{skills_section}
{bootstrap_section}
{skills_section}
{es_query_routing_section}
{bootstrap_section}
{deferred_tools_section}
@@ -663,10 +700,12 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
# style, clarification system, citation rules, response-style / critical-reminder
# defaults) and no personal memory injection. Only the mechanical sections an
# agent still needs to actually use its configured skills/tools are kept.
AGENT_ONLY_PROMPT_TEMPLATE = """{soul}
{network_policy_section}{skills_section}
{deferred_tools_section}
AGENT_ONLY_PROMPT_TEMPLATE = """{soul}
{network_policy_section}{skills_section}
{es_query_routing_section}
{deferred_tools_section}
{writing_mode_section}
@@ -719,12 +758,16 @@ def apply_agent_only_prompt_template(
soul = get_agent_soul(storage_agent_id)
skills_section = get_skills_prompt_section(
available_skills,
app_config=app_config,
display_enabled=rag_mode_enabled,
)
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
skills_section = get_skills_prompt_section(
available_skills,
app_config=app_config,
display_enabled=rag_mode_enabled,
)
es_query_routing_section = _build_es_query_routing_section(
app_config=app_config,
available_skills=available_skills,
)
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
n = max_concurrent_subagents
subagent_section = _build_subagent_section(n, app_config=app_config) if subagent_enabled else ""
@@ -766,9 +809,10 @@ RAG/reference display mode is enabled.
)
prompt = AGENT_ONLY_PROMPT_TEMPLATE.format(
soul=soul,
skills_section=skills_section,
deferred_tools_section=deferred_tools_section,
soul=soul,
skills_section=skills_section,
es_query_routing_section=es_query_routing_section,
deferred_tools_section=deferred_tools_section,
writing_mode_section=writing_mode_section,
subagent_section=subagent_section,
network_policy_section=network_policy_section,
@@ -1377,11 +1421,15 @@ def apply_prompt_template(
bootstrap_section = _build_bootstrap_section() if is_bootstrap else ""
# Get skills section
skills_section = get_skills_prompt_section(
available_skills,
app_config=app_config,
display_enabled=rag_mode_enabled,
)
skills_section = get_skills_prompt_section(
available_skills,
app_config=app_config,
display_enabled=rag_mode_enabled,
)
es_query_routing_section = _build_es_query_routing_section(
app_config=app_config,
available_skills=available_skills,
)
# Get deferred tools section (tool_search)
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
@@ -1438,9 +1486,10 @@ When a search, knowledge, skill, or MCP tool returns a result list:
# Format the prompt with dynamic skills and memory
prompt = SYSTEM_PROMPT_TEMPLATE.format(
agent_name=agent_name or storage_agent_id or "CM 助手",
soul=get_agent_soul(storage_agent_id),
skills_section=skills_section,
bootstrap_section=bootstrap_section,
soul=get_agent_soul(storage_agent_id),
skills_section=skills_section,
es_query_routing_section=es_query_routing_section,
bootstrap_section=bootstrap_section,
deferred_tools_section=deferred_tools_section,
writing_mode_section=writing_mode_section,
memory_context=memory_context,
@@ -10,7 +10,7 @@ from __future__ import annotations
from dataclasses import dataclass
from urllib.parse import urlsplit, urlunsplit
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
def normalize_service_url(value: str | None) -> str:
@@ -37,18 +37,12 @@ class WeKnoraConnectionConfig(BaseModel):
web_base_url: str = Field(default="", description="Optional internal WeKnora administration URL")
admin_email: str = Field(
default="",
description=(
"Optional WeKnora administrator email used by DeerFlow's server-side "
"WeKnora API client and detail-page iframe proxy. Prefer $ENV references."
),
description=("Optional WeKnora administrator email used by DeerFlow's server-side WeKnora API client and detail-page iframe proxy. Prefer $ENV references."),
)
admin_password: str = Field(
default="",
repr=False,
description=(
"Optional WeKnora administrator password used by DeerFlow's server-side "
"WeKnora API client and detail-page iframe proxy. Prefer $ENV references."
),
description=("Optional WeKnora administrator password used by DeerFlow's server-side WeKnora API client and detail-page iframe proxy. Prefer $ENV references."),
)
model_config = ConfigDict(extra="ignore")
@@ -58,10 +52,107 @@ class WeKnoraConnectionConfig(BaseModel):
return normalize_service_url(value)
class LocalWikiEmbeddingConfig(BaseModel):
enabled: bool = True
provider: str = "openai"
model: str = "bge-m3"
base_url: str = ""
api_key: str = Field(default="", repr=False)
dimensions: int | None = Field(default=1024, ge=1, le=65536)
batch_size: int = Field(default=32, ge=1, le=512)
timeout_seconds: float = Field(default=30, gt=0, le=300)
max_retries: int = Field(default=3, ge=0, le=10)
model_config = ConfigDict(extra="ignore")
@field_validator("provider")
@classmethod
def _validate_provider(cls, value: str) -> str:
normalized = value.strip().lower()
if normalized not in {"openai", "openai_compatible"}:
raise ValueError("local Wiki embedding provider must be openai or openai_compatible")
return normalized
@field_validator("base_url")
@classmethod
def _validate_url(cls, value: str) -> str:
return normalize_service_url(value)
class LocalWikiExternalApiConfig(BaseModel):
enabled: bool = False
api_key: str = Field(default="", repr=False)
previous_api_key: str = Field(default="", repr=False)
max_query_chars: int = Field(default=1000, ge=1, le=10000)
max_knowledge_bases: int = Field(default=20, ge=1, le=100)
max_results: int = Field(default=50, ge=1, le=100)
default_results: int = Field(default=10, ge=1, le=100)
include_content_max_chars: int = Field(default=12000, ge=0, le=1_000_000)
requests_per_minute: int = Field(default=60, ge=1, le=100000)
model_config = ConfigDict(extra="ignore")
@model_validator(mode="after")
def _validate_enabled_key(self):
if self.enabled and not self.api_key.strip():
raise ValueError("llmwiki.local_wiki_index.external_api.api_key is required when external API is enabled")
if self.default_results > self.max_results:
raise ValueError("external_api.default_results must not exceed max_results")
return self
class LocalWikiIndexConfig(BaseModel):
"""DeerFlow-owned mirror and exact-vector index for WeKnora Wiki pages."""
enabled: bool = False
sync_enabled: bool = False
internal_search_enabled: bool = False
reference_ui_enabled: bool = False
sync_interval_seconds: int = Field(default=120, ge=10, le=86400)
sync_page_size: int = Field(default=100, ge=1, le=500)
sync_concurrency: int = Field(default=3, ge=1, le=32)
sync_lease_seconds: int = Field(default=600, ge=30, le=86400)
sync_request_timeout_seconds: float = Field(default=30, gt=0, le=300)
sync_max_retries: int = Field(default=3, ge=0, le=10)
sync_retry_backoff_seconds: float = Field(default=5, ge=0, le=300)
chunk_max_chars: int = Field(default=1000, ge=100, le=20000)
chunk_overlap_chars: int = Field(default=120, ge=0, le=10000)
chunk_min_chars: int = Field(default=80, ge=1, le=10000)
title_prefix_enabled: bool = True
embedding: LocalWikiEmbeddingConfig = Field(default_factory=LocalWikiEmbeddingConfig)
engine: str = "numpy_exact"
strict_vector: bool = True
top_k_sections: int = Field(default=30, ge=1, le=1000)
top_k_pages: int = Field(default=8, ge=1, le=100)
max_sections_per_page: int = Field(default=2, ge=1, le=20)
min_similarity: float = Field(default=0.35, ge=-1, le=1)
keyword_boost_enabled: bool = False
keyword_boost_weight: float = Field(default=0.15, ge=0, le=1)
cache_max_knowledge_bases: int = Field(default=20, ge=1, le=1000)
cache_revision_check_seconds: float = Field(default=5, ge=0, le=3600)
external_api: LocalWikiExternalApiConfig = Field(default_factory=LocalWikiExternalApiConfig)
model_config = ConfigDict(extra="ignore")
@model_validator(mode="after")
def _validate_index(self):
if self.chunk_overlap_chars >= self.chunk_max_chars:
raise ValueError("chunk_overlap_chars must be smaller than chunk_max_chars")
if self.chunk_min_chars > self.chunk_max_chars:
raise ValueError("chunk_min_chars must not exceed chunk_max_chars")
if self.top_k_pages > self.top_k_sections:
raise ValueError("top_k_pages must not exceed top_k_sections")
if self.engine != "numpy_exact":
raise ValueError("Only the numpy_exact local Wiki vector engine is currently supported")
if self.enabled and not self.embedding.enabled:
raise ValueError("local Wiki index embedding must be enabled when the index is enabled")
if self.enabled and (not self.embedding.base_url or not self.embedding.model.strip()):
raise ValueError("local Wiki index embedding base_url and model are required when enabled")
return self
class LlmWikiConfig(BaseModel):
"""Top-level ``config.yaml`` section for conditional WeKnora mode."""
weknora: WeKnoraConnectionConfig = Field(default_factory=WeKnoraConnectionConfig)
local_wiki_index: LocalWikiIndexConfig = Field(default_factory=LocalWikiIndexConfig)
model_config = ConfigDict(extra="ignore")
@@ -5,8 +5,26 @@ from pydantic import BaseModel, Field
from deerflow.config.runtime_paths import project_root, resolve_path
class SecurityScanConfig(BaseModel):
DEFAULT_ES_QUERY_ROUTING_PROMPT = """当用户的问题需要基于 Elasticsearch 查询语句(Query DSL)灵活构造、调整或执行查询时,必须优先使用 `es_query` 技能。
典型场景包括:根据用户条件动态组合 bool/must/filter/should、范围、聚合、排序或分页等 ES 查询;或者用户直接提供、要求修改 ES 语句并据此查询数据。
执行前先读取 `es_query` 技能的 SKILL.md,严格按照技能中的流程和参数调用;不要用通用搜索替代,也不要凭记忆臆测查询结果。
仅当问题确实需要 ES 数据查询时使用;纯概念解释、普通代码说明或与 ES 无关的问题不要调用。"""
class EsQueryRoutingConfig(BaseModel):
"""Prompt steering that routes flexible Elasticsearch queries to ``es_query``."""
enabled: bool = Field(
default=False,
description="Inject the es_query routing prompt into the agent system prompt.",
)
prompt: str = Field(
default=DEFAULT_ES_QUERY_ROUTING_PROMPT,
description="Editable system-prompt instructions for deciding when and how to use es_query.",
)
class SecurityScanConfig(BaseModel):
"""Toggle for the LLM-based skill security scanner."""
enabled: bool = Field(
@@ -33,10 +51,14 @@ class SkillsConfig(BaseModel):
default="/mnt/skills",
description="Path where skills are mounted in the sandbox container",
)
security_scan: SecurityScanConfig = Field(
default_factory=SecurityScanConfig,
description="LLM security-scan toggle for skill content.",
)
security_scan: SecurityScanConfig = Field(
default_factory=SecurityScanConfig,
description="LLM security-scan toggle for skill content.",
)
es_query_routing: EsQueryRoutingConfig = Field(
default_factory=EsQueryRoutingConfig,
description="Optional configurable routing prompt for the es_query skill.",
)
def get_skills_path(self) -> Path:
"""
@@ -0,0 +1,7 @@
"""DeerFlow-local mirror and Wiki-only retrieval for WeKnora Wiki pages."""
from deerflow.integrations.weknora.local_index.retrieval import WikiRetrievalService, is_fully_vectorized
from deerflow.integrations.weknora.local_index.search import WikiIndexError, WikiVectorSearchService
from deerflow.integrations.weknora.local_index.sync import WikiSyncService
__all__ = ["WikiIndexError", "WikiRetrievalService", "WikiVectorSearchService", "WikiSyncService", "is_fully_vectorized"]
@@ -0,0 +1,108 @@
"""Markdown-aware Wiki section chunking."""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
_FRONTMATTER_RE = re.compile(r"\A\s*---\s*\n.*?\n---\s*(?:\n|\Z)", re.DOTALL)
_SCRIPT_STYLE_RE = re.compile(r"<\s*(script|style)\b[^>]*>.*?<\s*/\s*\1\s*>", re.IGNORECASE | re.DOTALL)
_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\([^)]*\)")
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
@dataclass(frozen=True)
class WikiSection:
section_index: int
heading: str | None
content: str
embedding_text: str
content_hash: str
def _clean_markdown(value: str) -> str:
text = value.replace("\r\n", "\n")
text = _FRONTMATTER_RE.sub("", text)
text = _SCRIPT_STYLE_RE.sub("", text)
text = _IMAGE_RE.sub(lambda match: match.group(1), text)
return re.sub(r"\n{3,}", "\n\n", text).strip()
def _windows(text: str, max_chars: int, overlap: int) -> list[str]:
paragraphs = [part.strip() for part in re.split(r"\n\s*\n", text) if part.strip()]
chunks: list[str] = []
buffer = ""
for paragraph in paragraphs:
if len(buffer) + len(paragraph) + 2 <= max_chars:
buffer = f"{buffer}\n\n{paragraph}".strip()
continue
if buffer:
chunks.append(buffer)
buffer = ""
if len(paragraph) <= max_chars:
buffer = paragraph
continue
step = max(1, max_chars - overlap)
chunks.extend(paragraph[start : start + max_chars] for start in range(0, len(paragraph), step))
if buffer:
chunks.append(buffer)
return chunks
def chunk_wiki_page(
page: dict,
*,
max_chars: int,
overlap: int,
min_chars: int,
title_prefix_enabled: bool,
) -> list[WikiSection]:
content = _clean_markdown(str(page.get("content_md") or ""))
heading_stack: list[str] = []
sections: list[tuple[str | None, str]] = []
current_heading: str | None = None
body: list[str] = []
for line in content.splitlines():
match = _HEADING_RE.match(line)
if not match:
body.append(line)
continue
if any(item.strip() for item in body):
sections.append((current_heading, "\n".join(body).strip()))
level = len(match.group(1))
heading_stack = heading_stack[: level - 1]
heading_stack.append(match.group(2).strip())
current_heading = " / ".join(heading_stack)
body = []
if any(item.strip() for item in body):
sections.append((current_heading, "\n".join(body).strip()))
if content and not sections:
sections.append((None, content))
summary = _clean_markdown(str(page.get("summary") or ""))
if summary:
sections.insert(0, ("页面摘要", summary))
prefix_lines = [f"页面:{page.get('title') or page.get('slug') or ''}"]
aliases = [str(item) for item in page.get("aliases") or [] if str(item).strip()]
if aliases:
prefix_lines.append(f"别名:{'、'.join(aliases)}")
if page.get("page_type"):
prefix_lines.append(f"类型:{page['page_type']}")
seen: set[str] = set()
result: list[WikiSection] = []
for heading, section_text in sections:
for chunk in _windows(section_text, max_chars, overlap):
normalized = re.sub(r"\s+", " ", chunk).strip()
if len(normalized) < min_chars and len(sections) > 1:
continue
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
if digest in seen:
continue
seen.add(digest)
context = list(prefix_lines) if title_prefix_enabled else []
if heading:
context.append(f"章节:{heading}")
embedding_text = "\n".join([*context, "", chunk]).strip()
result.append(WikiSection(len(result), heading, chunk, embedding_text, digest))
return result
@@ -0,0 +1,107 @@
"""Strict OpenAI-compatible embedding client for the Wiki index."""
from __future__ import annotations
import asyncio
import hashlib
import logging
from time import monotonic
import httpx
from deerflow.config.llmwiki_config import LocalWikiEmbeddingConfig
from deerflow.integrations.weknora.local_index.vector_codec import normalize_vector
logger = logging.getLogger(__name__)
class WikiEmbeddingError(RuntimeError):
def __init__(self, message: str, *, category: str = "request", retryable: bool = True) -> None:
super().__init__(message)
self.category = category
self.retryable = retryable
class StrictWikiEmbeddingClient:
def __init__(self, config: LocalWikiEmbeddingConfig, *, http_client: httpx.AsyncClient | None = None) -> None:
self.config = config
self._client = http_client
self._owns_client = http_client is None
self._resolved_dimensions = config.dimensions
@property
def dimensions(self) -> int | None:
return self._resolved_dimensions
@property
def fingerprint(self) -> str:
raw = "\0".join((self.config.provider.strip().lower(), self.config.base_url.rstrip("/").lower(), self.config.model.strip(), str(self._resolved_dimensions or "auto")))
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
async def close(self) -> None:
if self._owns_client and self._client is not None:
await self._client.aclose()
self._client = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(timeout=self.config.timeout_seconds)
return self._client
async def embed_texts(self, texts: list[str]) -> list[list[float]]:
if not self.config.enabled or not self.config.base_url or not self.config.model:
raise WikiEmbeddingError("Wiki embedding is not configured", category="configuration", retryable=False)
if not texts:
return []
vectors: list[list[float]] = []
batch_size = max(1, self.config.batch_size)
for start in range(0, len(texts), batch_size):
vectors.extend(await self._embed_batch(texts[start : start + batch_size]))
if len(vectors) != len(texts):
raise WikiEmbeddingError("Embedding response count does not match request", category="response", retryable=False)
return vectors
async def _embed_batch(self, texts: list[str]) -> list[list[float]]:
url = self.config.base_url.rstrip("/") + "/embeddings"
headers = {"Content-Type": "application/json"}
if self.config.api_key:
headers["Authorization"] = f"Bearer {self.config.api_key}"
payload: dict = {"model": self.config.model, "input": texts}
if self.config.dimensions:
payload["dimensions"] = self.config.dimensions
last_error: WikiEmbeddingError | None = None
for attempt in range(self.config.max_retries + 1):
started = monotonic()
try:
response = await (await self._get_client()).post(url, headers=headers, json=payload)
if response.status_code in {401, 403}:
raise WikiEmbeddingError("Embedding authentication failed", category="authentication", retryable=False)
if response.status_code == 429 or response.status_code >= 500:
raise WikiEmbeddingError(f"Embedding service returned HTTP {response.status_code}", category="upstream", retryable=True)
response.raise_for_status()
data = response.json()
items = sorted(data.get("data", []), key=lambda item: int(item.get("index", 0))) if isinstance(data, dict) else []
if len(items) != len(texts):
raise WikiEmbeddingError("Embedding response count mismatch", category="response", retryable=False)
result: list[list[float]] = []
for item in items:
vector = normalize_vector(item.get("embedding") or [], dimensions=self._resolved_dimensions)
if self._resolved_dimensions is None:
self._resolved_dimensions = int(vector.size)
result.append(vector.astype(float).tolist())
logger.info("Wiki embedding batch completed model=%s batch=%d duration_ms=%d", self.config.model, len(texts), int((monotonic() - started) * 1000))
return result
except WikiEmbeddingError as exc:
last_error = exc
except httpx.TimeoutException:
last_error = WikiEmbeddingError("Embedding request timed out", category="timeout", retryable=True)
except (httpx.HTTPError, ValueError, TypeError) as exc:
last_error = WikiEmbeddingError(f"Embedding request failed ({type(exc).__name__})", category="request", retryable=isinstance(exc, httpx.HTTPError))
if last_error is not None and (not last_error.retryable or attempt >= self.config.max_retries):
raise last_error
await asyncio.sleep(min(30.0, 0.5 * (2**attempt)))
raise last_error or WikiEmbeddingError("Embedding request failed")
async def embed_query(self, text: str) -> list[float]:
result = await self.embed_texts([text])
return result[0]
@@ -0,0 +1,86 @@
"""Normalize unstable WeKnora Wiki response shapes into the local contract."""
from __future__ import annotations
import hashlib
import json
import re
from datetime import datetime
from typing import Any
from urllib.parse import unquote
def normalize_slug(value: Any) -> str:
slug = unquote(str(value or "")).replace("\\", "/").strip().strip("/")
slug = re.sub(r"/{2,}", "/", slug)
if not slug:
raise ValueError("Wiki page slug is empty")
return slug
def slug_hash(slug: str) -> str:
return hashlib.sha256(normalize_slug(slug).encode("utf-8")).hexdigest()
def _list(value: Any) -> list:
if isinstance(value, list):
return value
if isinstance(value, tuple):
return list(value)
if isinstance(value, str) and value.strip():
return [value.strip()]
return []
def _datetime(value: Any) -> datetime | None:
if isinstance(value, datetime):
return value
raw = str(value or "").strip()
if not raw:
return None
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
def normalize_wiki_page(mapping_id: str, remote: dict[str, Any]) -> dict[str, Any]:
slug = normalize_slug(remote.get("slug") or remote.get("wiki_slug") or remote.get("path"))
aliases = sorted({str(item).strip() for item in _list(remote.get("aliases")) if str(item).strip()})
category_path = [str(item).strip() for item in _list(remote.get("category_path") or remote.get("categories")) if str(item).strip()]
title = str(remote.get("title") or slug.rsplit("/", 1)[-1]).strip()
summary = str(remote.get("summary") or remote.get("description") or "").replace("\r\n", "\n").strip()
content = str(remote.get("content_md") or remote.get("content") or remote.get("markdown") or "").replace("\r\n", "\n").strip()
status = str(remote.get("status") or "published").strip().lower()
if status not in {"draft", "published", "archived"}:
status = "published"
hash_payload = {
"title": title,
"aliases": aliases,
"summary": summary,
"content_md": content,
"page_type": str(remote.get("page_type") or remote.get("type") or "article").strip().lower(),
"status": status,
}
content_hash = hashlib.sha256(json.dumps(hash_payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
return {
"knowledge_base_mapping_id": mapping_id,
"remote_page_id": str(remote.get("id") or remote.get("page_id") or "").strip() or None,
"slug": slug,
"slug_hash": slug_hash(slug),
"title": title,
"summary": summary,
"content_md": content,
"page_type": hash_payload["page_type"],
"status": status,
"aliases": aliases,
"category_path": category_path,
"parent_slug": str(remote.get("parent_slug") or "").strip() or None,
"wiki_path": str(remote.get("wiki_path") or remote.get("path") or "").strip() or None,
"source_refs": _list(remote.get("source_refs") or remote.get("sources")),
"chunk_refs": _list(remote.get("chunk_refs") or remote.get("chunks")),
"page_version": int(remote.get("page_version") or remote.get("version") or 0),
"content_hash": content_hash,
"remote_created_at": _datetime(remote.get("created_at") or remote.get("createdAt")),
"remote_updated_at": _datetime(remote.get("updated_at") or remote.get("updatedAt")),
}
@@ -0,0 +1,248 @@
"""Wiki-only retrieval with local-vector preference and remote Wiki fallback.
The fallback deliberately calls WeKnora's ``/wiki/pages`` API. It never calls
the raw knowledge/chunk search endpoint, so callers always receive processed
Wiki pages even while a whole knowledge base is awaiting vectorization.
"""
from __future__ import annotations
import asyncio
from typing import Any
from urllib.parse import urlencode
from deerflow.integrations.weknora.client import WeKnoraClient
from deerflow.integrations.weknora.local_index.normalize import normalize_wiki_page
from deerflow.integrations.weknora.local_index.search import WikiIndexError, WikiVectorSearchService
from deerflow.persistence.llmwiki_index import LlmWikiIndexStore
def is_fully_vectorized(state: dict[str, Any] | None, fingerprint: str | None) -> bool:
"""Return whether a completed full scan produced one coherent active index."""
if not state or not fingerprint:
return False
if str(state.get("state") or "") != "idle" or not state.get("last_success_at"):
return False
if str(state.get("embedding_fingerprint") or "") != fingerprint:
return False
local_count = int(state.get("local_page_count") or 0)
ready_count = int(state.get("ready_page_count") or 0)
failed_count = int(state.get("failed_page_count") or 0)
vector_count = int(state.get("vector_count") or 0)
if failed_count or ready_count != local_count:
return False
return local_count == 0 or vector_count > 0
class WikiRetrievalService:
"""Search complete indexes locally and incomplete indexes through Wiki API."""
def __init__(
self,
store: LlmWikiIndexStore,
*,
vector_search: WikiVectorSearchService | None,
client: WeKnoraClient | None,
) -> None:
self.store = store
self.vector_search = vector_search
self.client = client
async def search(
self,
query: str,
mappings: list[dict[str, Any]],
*,
top_k_pages: int = 8,
include_drafts: bool = False,
) -> dict[str, Any]:
normalized_query = query.strip()
if not normalized_query:
raise WikiIndexError("WIKI_QUERY_EMPTY", "Search query is empty", status_code=400)
if not mappings:
return {
"query": normalized_query,
"mode": "wiki_api",
"results": [],
"knowledge_base_count": 0,
"partial": False,
"warnings": [],
"fallback_knowledge_base_ids": [],
}
vector_mappings, fallback_mappings = await self._partition_mappings(mappings)
results: list[dict[str, Any]] = []
warnings: list[str] = []
vector_used = False
fallback_used = False
if vector_mappings and self.vector_search is not None:
try:
payload = await self.vector_search.search(
normalized_query,
vector_mappings,
top_k_pages=max(top_k_pages, len(vector_mappings)),
include_drafts=include_drafts,
)
results.extend(payload.get("results") or [])
warnings.extend(str(item) for item in payload.get("warnings") or [])
vector_used = True
except WikiIndexError as exc:
# A status row can become stale between classification and
# matrix loading. Wiki API remains a safe Wiki-only fallback.
warnings.append(f"local_vector:{exc.code}")
fallback_mappings.extend(vector_mappings)
vector_mappings = []
fallback_ids = [str(mapping["id"]) for mapping in fallback_mappings]
if fallback_mappings:
if self.client is None:
warnings.extend(f"{mapping['id']}:WIKI_API_UNAVAILABLE" for mapping in fallback_mappings)
else:
batches = await asyncio.gather(
*(
self._search_remote_mapping(
normalized_query,
mapping,
limit=max(top_k_pages, 1),
include_drafts=include_drafts,
)
for mapping in fallback_mappings
),
return_exceptions=True,
)
for mapping, batch in zip(fallback_mappings, batches, strict=True):
if isinstance(batch, Exception):
warnings.append(f"{mapping['id']}:WIKI_API_SEARCH_FAILED")
else:
results.extend(batch)
fallback_used = True
if warnings and not results and not vector_used and not fallback_used:
raise WikiIndexError(
"WIKI_API_UNAVAILABLE",
"Wiki page retrieval is unavailable",
status_code=502 if self.client is not None else 503,
)
# De-duplicate by the public Wiki citation identity. Vector scores and
# Wiki-API rank scores are both descending relevance hints.
unique: dict[tuple[str, str], dict[str, Any]] = {}
for item in results:
key = (str(item.get("knowledge_base_id") or ""), str(item.get("wiki_slug") or item.get("id") or ""))
current = unique.get(key)
if current is None or float(item.get("score") or 0) > float(current.get("score") or 0):
unique[key] = item
ranked = sorted(unique.values(), key=lambda item: float(item.get("score") or 0), reverse=True)
mode = "mixed" if vector_used and fallback_used else "vector" if vector_used else "wiki_api"
fingerprint = self.vector_search.embedding.fingerprint[:12] if vector_used and self.vector_search is not None else None
return {
"query": normalized_query,
"mode": mode,
"results": ranked[:top_k_pages],
"knowledge_base_count": len(mappings),
"index_fingerprint": fingerprint,
"partial": bool(warnings),
"warnings": warnings,
"fallback_knowledge_base_ids": fallback_ids,
}
async def _partition_mappings(self, mappings: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
if self.vector_search is None:
return [], list(mappings)
mapping_ids = [str(mapping["id"]) for mapping in mappings]
states = {
str(state["knowledge_base_mapping_id"]): state
for state in await self.store.list_index_status(mapping_ids)
}
fingerprint = self.vector_search.embedding.fingerprint
vector_mappings = [
mapping
for mapping in mappings
if mapping.get("wiki_index_enabled", True)
and is_fully_vectorized(states.get(str(mapping["id"])), fingerprint)
]
ready_ids = {str(mapping["id"]) for mapping in vector_mappings}
return vector_mappings, [mapping for mapping in mappings if str(mapping["id"]) not in ready_ids]
async def _search_remote_mapping(
self,
query: str,
mapping: dict[str, Any],
*,
limit: int,
include_drafts: bool,
) -> list[dict[str, Any]]:
if self.client is None:
return []
remote_id = str(mapping["weknora_id"])
listing = await self.client.list_wiki_pages(
remote_id,
page=1,
page_size=min(max(limit * 3, 20), 100),
query=query,
)
summaries = [item for item in listing.get("pages", []) if isinstance(item, dict)]
slugs = list(
dict.fromkeys(
str(item.get("slug") or item.get("wiki_slug") or item.get("path") or "").strip("/")
for item in summaries
if str(item.get("slug") or item.get("wiki_slug") or item.get("path") or "").strip("/")
)
)[: max(limit * 2, limit)]
details = await asyncio.gather(
*(self.client.get_wiki_page(remote_id, slug) for slug in slugs),
return_exceptions=True,
)
results: list[dict[str, Any]] = []
for rank, (slug, detail) in enumerate(zip(slugs, details, strict=True)):
if isinstance(detail, Exception):
continue
try:
page = normalize_wiki_page(str(mapping["id"]), detail)
except (TypeError, ValueError):
continue
if page["status"] == "archived" or (page["status"] == "draft" and not include_drafts):
continue
params = urlencode({"knowledge_base_id": mapping["id"], "wiki_slug": slug})
content = str(page.get("content_md") or "")
score = float(detail.get("score") or max(0.01, 1.0 - rank * 0.05))
results.append(
{
"kind": "wiki_page",
"id": page.get("remote_page_id") or f"{mapping['id']}:{slug}",
"knowledge_base_id": mapping["id"],
"knowledge_base_name": mapping.get("name") or "",
"wiki_page_id": page.get("remote_page_id") or f"{mapping['id']}:{slug}",
"wiki_slug": slug,
"title": page.get("title") or slug,
"summary": page.get("summary") or "",
"page_type": page.get("page_type") or "article",
"page_version": int(page.get("page_version") or 0),
"updated_at": page.get("remote_updated_at"),
"score": score,
"content": content,
"snippet": content[:360],
"matched_heading": None,
"matched_sections": [
{
"section_index": 0,
"heading": None,
"content": content,
"score": score,
}
],
"url": f"/weknora-source-preview?{params}",
"source": mapping.get("name") or "LLMWiki",
"type": "llmwiki_wiki",
"mode": "citation",
"retrieval_mode": "wiki_api",
}
)
if len(results) >= limit:
break
return results
__all__ = ["WikiRetrievalService", "is_fully_vectorized"]
@@ -0,0 +1,48 @@
"""App-to-harness runtime injection for local Wiki index services."""
from __future__ import annotations
from deerflow.integrations.weknora.local_index.retrieval import WikiRetrievalService
from deerflow.integrations.weknora.local_index.search import WikiVectorSearchService
from deerflow.integrations.weknora.local_index.sync import WikiSyncService
from deerflow.persistence.llmwiki_index import LlmWikiIndexStore
_store: LlmWikiIndexStore | None = None
_search: WikiVectorSearchService | None = None
_sync: WikiSyncService | None = None
_retrieval: WikiRetrievalService | None = None
def register_local_wiki_index_runtime(
*,
store: LlmWikiIndexStore | None,
search: WikiVectorSearchService | None,
sync: WikiSyncService | None,
retrieval: WikiRetrievalService | None = None,
) -> None:
global _store, _search, _sync, _retrieval
_store, _search, _sync, _retrieval = store, search, sync, retrieval
def get_local_wiki_index_store() -> LlmWikiIndexStore:
if _store is None:
raise RuntimeError("Local Wiki index store is not initialized")
return _store
def get_local_wiki_search_service() -> WikiVectorSearchService:
if _search is None:
raise RuntimeError("Local Wiki vector search is not initialized")
return _search
def get_local_wiki_sync_service() -> WikiSyncService:
if _sync is None:
raise RuntimeError("Local Wiki sync service is not initialized")
return _sync
def get_local_wiki_retrieval_service() -> WikiRetrievalService:
if _retrieval is None:
raise RuntimeError("Wiki-only retrieval service is not initialized")
return _retrieval
@@ -0,0 +1,176 @@
"""Unified exact-vector search over DeerFlow-local Wiki section snapshots."""
from __future__ import annotations
import asyncio
import hashlib
import re
from collections import OrderedDict, defaultdict
from time import monotonic
from typing import Any
from urllib.parse import urlencode
import numpy as np
from deerflow.config.llmwiki_config import LocalWikiIndexConfig
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient, WikiEmbeddingError
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache, WikiVectorSnapshot
from deerflow.integrations.weknora.local_index.vector_codec import normalize_vector
class WikiIndexError(RuntimeError):
def __init__(self, code: str, message: str, *, status_code: int) -> None:
super().__init__(message)
self.code = code
self.status_code = status_code
class WikiVectorSearchService:
def __init__(self, config: LocalWikiIndexConfig, embedding: StrictWikiEmbeddingClient, cache: WikiVectorCache) -> None:
self.config = config
self.embedding = embedding
self.cache = cache
self._query_vectors: OrderedDict[str, tuple[float, np.ndarray]] = OrderedDict()
async def search(
self,
query: str,
mappings: list[dict[str, Any]],
*,
top_k_pages: int | None = None,
include_drafts: bool = False,
external: bool = False,
) -> dict[str, Any]:
normalized_query = query.strip()
if not normalized_query:
raise WikiIndexError("WIKI_QUERY_EMPTY", "Search query is empty", status_code=400)
if not mappings:
return {"query": normalized_query, "mode": "vector", "results": [], "knowledge_base_count": 0, "index_fingerprint": self.embedding.fingerprint[:12], "partial": False, "warnings": []}
try:
query_vector = await self._embed_query(normalized_query)
except (WikiEmbeddingError, ValueError) as exc:
raise WikiIndexError("WIKI_EMBEDDING_UNAVAILABLE", str(exc), status_code=503) from None
fingerprint = self.embedding.fingerprint
tasks = [self._search_mapping(normalized_query, query_vector, mapping, fingerprint, include_drafts=include_drafts, external=external) for mapping in mappings]
batches = await asyncio.gather(*tasks, return_exceptions=True)
candidates: list[dict[str, Any]] = []
warnings: list[str] = []
for mapping, batch in zip(mappings, batches, strict=True):
if isinstance(batch, Exception):
warnings.append(f"{mapping.get('id')}: {batch}")
else:
candidates.extend(batch)
if warnings and not candidates and len(warnings) == len(mappings):
if any("FINGERPRINT" in item for item in warnings):
code = "WIKI_INDEX_FINGERPRINT_MISMATCH"
elif any("rebuild" in item.lower() for item in warnings):
code = "WIKI_INDEX_REBUILDING"
else:
code = "WIKI_INDEX_NOT_READY"
raise WikiIndexError(code, "Local Wiki index is not ready", status_code=409)
candidates.sort(key=lambda item: item["score"], reverse=True)
grouped: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
for candidate in candidates:
page = candidate["page"]
key = (str(page["knowledge_base_mapping_id"]), str(page["id"]))
if len(grouped[key]) < self.config.max_sections_per_page:
grouped[key].append(candidate)
results: list[dict[str, Any]] = []
for sections in grouped.values():
first = sections[0]
page = first["page"]
mapping = first["mapping"]
second_score = sections[1]["score"] if len(sections) > 1 else 0.0
page_score = first["score"] + (0.05 * second_score if second_score else 0)
params = urlencode({"knowledge_base_id": mapping["id"], "wiki_slug": page["slug"]})
results.append(
{
"kind": "wiki_page",
"id": page["id"],
"knowledge_base_id": mapping["id"],
"knowledge_base_name": mapping.get("name") or "",
"wiki_page_id": page["id"],
"wiki_slug": page["slug"],
"title": page.get("title") or page["slug"],
"summary": page.get("summary") or "",
"page_type": page.get("page_type") or "article",
"page_version": int(page.get("page_version") or 0),
"updated_at": page.get("remote_updated_at") or page.get("updated_at"),
"score": float(page_score),
"content": "\n\n".join(str(item["section_content"]) for item in sections),
"snippet": str(sections[0]["section_content"])[:360],
"matched_heading": sections[0].get("heading"),
"matched_sections": [{"section_index": int(item["section_index"]), "heading": item.get("heading"), "content": item["section_content"], "score": float(item["score"])} for item in sections],
"url": f"/weknora-source-preview?{params}",
"source": mapping.get("name") or "LLMWiki",
"type": "llmwiki_wiki",
"mode": "citation",
}
)
results.sort(key=lambda item: item["score"], reverse=True)
limit = min(top_k_pages or self.config.top_k_pages, self.config.top_k_pages if top_k_pages is None else 50)
return {"query": normalized_query, "mode": "vector", "results": results[:limit], "knowledge_base_count": len(mappings), "index_fingerprint": fingerprint[:12], "partial": bool(warnings), "warnings": warnings}
async def _embed_query(self, query: str) -> np.ndarray:
digest = hashlib.sha256(query.encode("utf-8")).hexdigest()
key = f"{self.embedding.fingerprint}:{digest}"
cached = self._query_vectors.get(key)
if cached is not None and monotonic() - cached[0] <= 300:
self._query_vectors.move_to_end(key)
return cached[1]
vector = normalize_vector(await self.embedding.embed_query(query), dimensions=self.embedding.dimensions)
key = f"{self.embedding.fingerprint}:{digest}"
self._query_vectors[key] = (monotonic(), vector)
self._query_vectors.move_to_end(key)
while len(self._query_vectors) > 256:
self._query_vectors.popitem(last=False)
return vector
async def _search_mapping(self, query: str, vector: np.ndarray, mapping: dict[str, Any], fingerprint: str, *, include_drafts: bool, external: bool) -> list[dict[str, Any]]:
mapping_id = str(mapping["id"])
try:
snapshot = await self.cache.get(mapping_id, fingerprint)
except ValueError as exc:
await self.cache.store.mark_rebuild_required([mapping_id], fingerprint)
self.cache.invalidate(mapping_id)
raise RuntimeError("WIKI_INDEX_REBUILD_REQUIRED") from exc
if snapshot.matrix.size == 0:
return []
if snapshot.dimensions != vector.size:
raise RuntimeError("WIKI_INDEX_FINGERPRINT_MISMATCH")
eligible = np.asarray(
[
index
for index, row in enumerate(snapshot.rows)
if row["page"].get("status") != "draft" or (include_drafts and not external)
],
dtype=int,
)
if eligible.size == 0:
return []
indices, scores = await asyncio.to_thread(self._top_indices, snapshot, vector, eligible)
terms = {term.lower() for term in re.findall(r"[\w\u4e00-\u9fff]{2,}", query)}
candidates = []
for index, score in zip(indices, scores, strict=True):
row = snapshot.rows[int(index)]
page = row["page"]
vector_score = float(score)
if vector_score < self.config.min_similarity:
continue
final_score = vector_score
if self.config.keyword_boost_enabled and terms:
haystack = f"{page.get('title', '')} {row.get('heading', '')} {row.get('section_content', '')}".lower()
keyword_score = sum(term in haystack for term in terms) / len(terms)
weight = self.config.keyword_boost_weight
final_score = (1 - weight) * vector_score + weight * keyword_score
candidates.append({**row, "page": page, "mapping": mapping, "score": final_score})
return candidates
def _top_indices(self, snapshot: WikiVectorSnapshot, vector: np.ndarray, eligible: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
scores = snapshot.matrix[eligible] @ vector
count = min(self.config.top_k_sections, scores.size)
if count <= 0:
return np.asarray([], dtype=int), np.asarray([], dtype=np.float32)
positions = np.argpartition(scores, -count)[-count:]
positions = positions[np.argsort(scores[positions])[::-1]]
return eligible[positions], scores[positions]
@@ -0,0 +1,173 @@
"""Lease-protected full Wiki mirror scan and page-level vector replacement."""
from __future__ import annotations
import asyncio
import logging
import platform
from typing import Any
from uuid import uuid4
from deerflow.config.llmwiki_config import LocalWikiIndexConfig
from deerflow.integrations.weknora.client import WeKnoraClient
from deerflow.integrations.weknora.local_index.chunking import chunk_wiki_page
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient
from deerflow.integrations.weknora.local_index.normalize import normalize_wiki_page
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache
from deerflow.integrations.weknora.local_index.vector_codec import encode_vector
from deerflow.persistence.llmwiki_index import LlmWikiIndexStore
logger = logging.getLogger(__name__)
class WikiSyncLeaseLost(RuntimeError):
pass
def _safe_error(exc: BaseException) -> str:
return f"{type(exc).__name__}: {str(exc)[:1000]}"
class WikiSyncService:
def __init__(self, config: LocalWikiIndexConfig, store: LlmWikiIndexStore, embedding: StrictWikiEmbeddingClient, cache: WikiVectorCache, client: WeKnoraClient) -> None:
self.config = config
self.store = store
self.embedding = embedding
self.cache = cache
self.client = client
self.owner = f"{platform.node()}:{uuid4()}"
async def sync_mapping(self, mapping: dict[str, Any], *, force: bool = False) -> dict[str, Any]:
mapping_id = str(mapping["id"])
sync_id = str(uuid4())
if self.embedding.dimensions is None:
# Auto-dimension configurations need one successful response to
# produce a stable fingerprint before lease/rebuild decisions.
await self.embedding.embed_query("DeerFlow local Wiki embedding dimension probe")
fingerprint = self.embedding.fingerprint
revision, indexed_fingerprint, prior_state = await self.store.get_index_revision(mapping_id)
if revision > 0 and indexed_fingerprint and indexed_fingerprint != fingerprint and prior_state not in {"rebuild_required", "rebuilding"}:
await self.store.mark_rebuild_required([mapping_id], fingerprint)
self.cache.invalidate(mapping_id)
prior_state = "rebuild_required"
rebuilding = prior_state == "rebuild_required"
acquired = await self.store.try_acquire_sync_lease(mapping_id, owner=self.owner, sync_id=sync_id, lease_seconds=self.config.sync_lease_seconds, fingerprint=fingerprint)
if not acquired:
return {"mapping_id": mapping_id, "status": "already_running"}
lease_lost = asyncio.Event()
async def _lease_heartbeat() -> None:
interval = max(5.0, min(60.0, self.config.sync_lease_seconds / 3))
while True:
await asyncio.sleep(interval)
try:
renewed = await self.store.renew_sync_lease(
mapping_id,
owner=self.owner,
lease_seconds=self.config.sync_lease_seconds,
)
except Exception:
renewed = False
logger.exception("Local Wiki sync lease heartbeat failed mapping_id=%s", mapping_id)
if not renewed:
lease_lost.set()
return
heartbeat = asyncio.create_task(_lease_heartbeat())
def _ensure_lease() -> None:
if lease_lost.is_set():
raise WikiSyncLeaseLost("Local Wiki sync lease was lost")
remote_count = 0
changed = 0
failed = 0
try:
remote_id = str(mapping["weknora_id"])
await self.client.get_knowledge_base(remote_id)
page_number = 1
while True:
batch = await self.client.list_wiki_pages(remote_id, page=page_number, page_size=self.config.sync_page_size)
pages = [page for page in batch.get("pages", []) if isinstance(page, dict)]
remote_count += len(pages)
for summary in pages:
try:
_ensure_lease()
slug = str(summary.get("slug") or summary.get("wiki_slug") or summary.get("path") or "")
remote_page = await self.client.get_wiki_page(remote_id, slug)
page = normalize_wiki_page(mapping_id, {**summary, **remote_page})
existing = await self.store.get_page_by_slug_hash(mapping_id, page["slug_hash"])
active_fingerprint = self.embedding.fingerprint
if (
not force
and existing
and existing.get("content_hash") == page["content_hash"]
and existing.get("indexed_fingerprint") == active_fingerprint
and existing.get("index_status") == "ready"
and not existing.get("is_remote_deleted")
):
await self.store.touch_page(str(existing["id"]), sync_id)
continue
sections = (
[]
if page["status"] == "archived"
else chunk_wiki_page(page, max_chars=self.config.chunk_max_chars, overlap=self.config.chunk_overlap_chars, min_chars=self.config.chunk_min_chars, title_prefix_enabled=self.config.title_prefix_enabled)
)
embeddings = await self.embedding.embed_texts([section.embedding_text for section in sections]) if sections else []
fingerprint = self.embedding.fingerprint
vectors = []
for section, vector in zip(sections, embeddings, strict=True):
blob, dimensions = encode_vector(vector, dimensions=self.embedding.dimensions)
vectors.append(
{"section_index": section.section_index, "heading": section.heading, "section_content": section.content, "content_hash": section.content_hash, "embedding_dimensions": dimensions, "vector_blob": blob}
)
_ensure_lease()
await self.store.replace_page_vectors(page=page, vectors=vectors, fingerprint=fingerprint, sync_id=sync_id)
self.cache.invalidate(mapping_id)
changed += 1
except Exception as exc:
if isinstance(exc, WikiSyncLeaseLost):
raise
failed += 1
try:
fallback = normalize_wiki_page(mapping_id, summary)
await self.store.record_page_failure(page=fallback, sync_id=sync_id, error=_safe_error(exc))
except Exception as record_exc:
logger.exception("Could not record local Wiki page failure mapping_id=%s", mapping_id)
raise RuntimeError("Could not safely retain a failed Wiki page during the full scan") from record_exc
logger.warning("Local Wiki page sync failed mapping_id=%s: %s", mapping_id, _safe_error(exc))
_ensure_lease()
total_pages = int(batch.get("total_pages") or 1)
if page_number >= total_pages:
break
page_number += 1
_ensure_lease()
deleted = await self.store.mark_missing_pages_deleted(mapping_id, sync_id)
if deleted:
self.cache.invalidate(mapping_id)
await self.store.finish_sync(
mapping_id,
owner=self.owner,
sync_id=sync_id,
success=not (rebuilding and failed),
remote_page_count=remote_count,
error=f"{failed} page(s) failed" if failed else None,
)
return {"mapping_id": mapping_id, "status": "partial" if failed else "completed", "remote_pages": remote_count, "changed_pages": changed, "deleted_pages": deleted, "failed_pages": failed}
except asyncio.CancelledError:
await self.store.finish_sync(
mapping_id,
owner=self.owner,
sync_id=sync_id,
success=False,
remote_page_count=remote_count,
error="Sync cancelled",
)
raise
except Exception as exc:
await self.store.finish_sync(mapping_id, owner=self.owner, sync_id=sync_id, success=False, remote_page_count=remote_count, error=_safe_error(exc))
logger.warning("Local Wiki sync failed mapping_id=%s: %s", mapping_id, _safe_error(exc))
raise
finally:
heartbeat.cancel()
await asyncio.gather(heartbeat, return_exceptions=True)
@@ -0,0 +1,86 @@
"""Per-knowledge-base LRU snapshots for NumPy exact search."""
from __future__ import annotations
import asyncio
from collections import OrderedDict
from dataclasses import dataclass
from time import monotonic
from typing import Any
import numpy as np
from deerflow.integrations.weknora.local_index.vector_codec import decode_vector
from deerflow.persistence.llmwiki_index import LlmWikiIndexStore
@dataclass(frozen=True)
class WikiVectorSnapshot:
mapping_id: str
index_revision: int
embedding_fingerprint: str
dimensions: int
matrix: np.ndarray
rows: list[dict[str, Any]]
class WikiVectorCache:
def __init__(self, store: LlmWikiIndexStore, *, max_knowledge_bases: int = 20, revision_check_seconds: float = 5) -> None:
self.store = store
self.max_knowledge_bases = max_knowledge_bases
self.revision_check_seconds = revision_check_seconds
self._snapshots: OrderedDict[str, WikiVectorSnapshot] = OrderedDict()
self._checked_at: dict[str, float] = {}
self._locks: dict[str, asyncio.Lock] = {}
def invalidate(self, mapping_id: str) -> None:
self._snapshots.pop(mapping_id, None)
self._checked_at.pop(mapping_id, None)
def clear(self) -> None:
self._snapshots.clear()
self._checked_at.clear()
async def get(self, mapping_id: str, fingerprint: str) -> WikiVectorSnapshot:
cached = self._snapshots.get(mapping_id)
now = monotonic()
if cached is not None and cached.embedding_fingerprint == fingerprint and now - self._checked_at.get(mapping_id, 0) < self.revision_check_seconds:
self._snapshots.move_to_end(mapping_id)
return cached
lock = self._locks.setdefault(mapping_id, asyncio.Lock())
async with lock:
revision, active_fingerprint, state = await self.store.get_index_revision(mapping_id)
self._checked_at[mapping_id] = monotonic()
cached = self._snapshots.get(mapping_id)
if state in {"not_synced", "rebuild_required", "rebuilding", "disabled"} or revision <= 0:
raise RuntimeError(f"WIKI_INDEX_NOT_READY:{state}")
if active_fingerprint != fingerprint:
raise RuntimeError("WIKI_INDEX_FINGERPRINT_MISMATCH")
if cached is not None and cached.index_revision == revision and cached.embedding_fingerprint == fingerprint:
self._snapshots.move_to_end(mapping_id)
return cached
raw = await self.store.load_vector_snapshot(mapping_id, fingerprint)
snapshot = await asyncio.to_thread(self._decode_snapshot, mapping_id, raw)
self._snapshots[mapping_id] = snapshot
self._snapshots.move_to_end(mapping_id)
while len(self._snapshots) > self.max_knowledge_bases:
evicted, _ = self._snapshots.popitem(last=False)
self._checked_at.pop(evicted, None)
return snapshot
@staticmethod
def _decode_snapshot(mapping_id: str, raw: dict[str, Any]) -> WikiVectorSnapshot:
rows = list(raw.get("rows") or [])
if not rows:
return WikiVectorSnapshot(mapping_id, int(raw.get("revision") or 0), str(raw.get("fingerprint") or ""), 0, np.empty((0, 0), dtype=np.float32), [])
dimensions = int(rows[0]["embedding_dimensions"])
vectors = []
valid_rows = []
for row in rows:
if int(row["embedding_dimensions"]) != dimensions:
raise ValueError("Mixed vector dimensions in one Wiki snapshot")
vectors.append(decode_vector(row["vector_blob"], dimensions))
valid_rows.append(row)
matrix = np.ascontiguousarray(np.vstack(vectors), dtype=np.float32)
matrix.setflags(write=False)
return WikiVectorSnapshot(mapping_id, int(raw.get("revision") or 0), str(raw.get("fingerprint") or ""), dimensions, matrix, valid_rows)
@@ -0,0 +1,33 @@
"""Validated little-endian normalized float32 Wiki vector codec."""
from __future__ import annotations
import numpy as np
def normalize_vector(values, *, dimensions: int | None = None) -> np.ndarray:
vector = np.asarray(values, dtype=np.float32)
if vector.ndim != 1 or vector.size == 0:
raise ValueError("Embedding vector must be a non-empty one-dimensional array")
if dimensions is not None and vector.size != dimensions:
raise ValueError(f"Embedding dimension mismatch: expected {dimensions}, got {vector.size}")
if not np.isfinite(vector).all():
raise ValueError("Embedding vector contains NaN or infinity")
norm = float(np.linalg.norm(vector))
if not np.isfinite(norm) or norm <= 0:
raise ValueError("Embedding vector has zero norm")
return np.asarray(vector / norm, dtype="<f4")
def encode_vector(values, *, dimensions: int | None = None) -> tuple[bytes, int]:
vector = normalize_vector(values, dimensions=dimensions)
return vector.tobytes(order="C"), int(vector.size)
def decode_vector(blob: bytes, dimensions: int) -> np.ndarray:
if len(blob) != dimensions * 4:
raise ValueError(f"Corrupt vector BLOB: expected {dimensions * 4} bytes, got {len(blob)}")
vector = np.frombuffer(blob, dtype="<f4")
if not np.isfinite(vector).all():
raise ValueError("Stored vector contains NaN or infinity")
return vector.copy()
@@ -0,0 +1,22 @@
"""Exact-match fixed-question persistence."""
from __future__ import annotations
from deerflow.persistence.fixed_questions.base import FixedQuestionStore
from deerflow.persistence.fixed_questions.memory import MemoryFixedQuestionStore
from deerflow.persistence.fixed_questions.model import FixedQuestionRow
from deerflow.persistence.fixed_questions.sql import FixedQuestionRepository
__all__ = [
"FixedQuestionRepository",
"FixedQuestionRow",
"FixedQuestionStore",
"MemoryFixedQuestionStore",
"make_fixed_question_store",
]
def make_fixed_question_store(session_factory):
if session_factory is None:
return MemoryFixedQuestionStore()
return FixedQuestionRepository(session_factory)
@@ -0,0 +1,30 @@
"""Abstract persistence API for administrator-managed fixed questions."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class FixedQuestionStore(ABC):
"""Global fixed-question configuration shared by every account."""
@abstractmethod
async def list_questions(self) -> list[dict[str, Any]]:
"""Return every configured question in display order."""
raise NotImplementedError
@abstractmethod
async def find_enabled(self, question: str) -> dict[str, Any] | None:
"""Return the enabled row whose question exactly equals ``question``."""
raise NotImplementedError
@abstractmethod
async def replace_all(
self,
rows: list[dict[str, Any]],
*,
updated_by: str | None = None,
) -> list[dict[str, Any]]:
"""Atomically replace the complete fixed-question configuration."""
raise NotImplementedError
@@ -0,0 +1,56 @@
"""In-memory fixed-question store used by memory deployments and tests."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.fixed_questions.base import FixedQuestionStore
def _normalize(data: dict[str, Any], updated_by: str | None) -> dict[str, Any]:
return {
"id": str(data["id"]),
"question": str(data.get("question") or ""),
"answer": str(data.get("answer") or ""),
"enabled": bool(data.get("enabled", True)),
"tokens_per_second": int(data.get("tokens_per_second", 100)),
"sort_order": int(data.get("sort_order", 0)),
"updated_by": updated_by,
"updated_at": datetime.now(UTC).isoformat(),
}
class MemoryFixedQuestionStore(FixedQuestionStore):
def __init__(self) -> None:
self._rows: dict[str, dict[str, Any]] = {}
async def list_questions(self) -> list[dict[str, Any]]:
rows = sorted(
self._rows.values(),
key=lambda row: (row.get("sort_order", 0), row.get("question", "")),
)
return [dict(row) for row in rows]
async def find_enabled(self, question: str) -> dict[str, Any] | None:
for row in self._rows.values():
if row["enabled"] and row["question"] == question:
return dict(row)
return None
async def replace_all(
self,
rows: list[dict[str, Any]],
*,
updated_by: str | None = None,
) -> list[dict[str, Any]]:
# Last duplicate question wins, mirroring the SQL repository.
by_id: dict[str, dict[str, Any]] = {}
for row in rows:
question = row.get("question")
row_id = row.get("id")
if row_id and question not in (None, ""):
by_id[str(row_id)] = row
by_question = {str(row["question"]): row for row in by_id.values()}
self._rows = {str(row["id"]): _normalize(row, updated_by) for row in by_question.values()}
return await self.list_questions()
@@ -0,0 +1,41 @@
"""ORM model for exact-match, administrator-managed fixed questions."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableLongText
class FixedQuestionRow(Base):
"""One globally shared question/answer pair.
``question_hash`` provides a portable indexed lookup for questions stored in
``Text``. The repository always compares the original question as well, so a
theoretical SHA-256 collision cannot cause a false match.
"""
__tablename__ = "fixed_questions"
__table_args__ = (UniqueConstraint("question_hash", name="uq_fixed_questions_question_hash"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
question: Mapped[str] = mapped_column(Text, nullable=False)
question_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
answer: Mapped[str] = mapped_column(PortableLongText, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1")
tokens_per_second: Mapped[int] = mapped_column(
Integer,
default=100,
server_default="100",
)
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,88 @@
"""SQLAlchemy-backed fixed-question store."""
from __future__ import annotations
import hashlib
from datetime import datetime
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.fixed_questions.base import FixedQuestionStore
from deerflow.persistence.fixed_questions.model import FixedQuestionRow
def question_hash(question: str) -> str:
return hashlib.sha256(question.encode("utf-8")).hexdigest()
def _to_dict(row: FixedQuestionRow) -> dict[str, Any]:
data = row.to_dict()
data.pop("question_hash", None)
value = data.get("updated_at")
if isinstance(value, datetime):
data["updated_at"] = value.isoformat()
return data
def _row_from(data: dict[str, Any], updated_by: str | None) -> FixedQuestionRow:
question = str(data.get("question") or "")
return FixedQuestionRow(
id=str(data["id"]),
question=question,
question_hash=question_hash(question),
answer=str(data.get("answer") or ""),
enabled=bool(data.get("enabled", True)),
tokens_per_second=int(data.get("tokens_per_second", 100)),
sort_order=int(data.get("sort_order", 0)),
updated_by=updated_by,
)
class FixedQuestionRepository(FixedQuestionStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_questions(self) -> list[dict[str, Any]]:
stmt = select(FixedQuestionRow).order_by(
FixedQuestionRow.sort_order.asc(),
FixedQuestionRow.question.asc(),
)
async with self._sf() as session:
result = await session.execute(stmt)
return [_to_dict(row) for row in result.scalars()]
async def find_enabled(self, question: str) -> dict[str, Any] | None:
stmt = select(FixedQuestionRow).where(
FixedQuestionRow.question_hash == question_hash(question),
FixedQuestionRow.enabled.is_(True),
)
async with self._sf() as session:
row = (await session.execute(stmt)).scalar_one_or_none()
if row is None or row.question != question:
return None
return _to_dict(row)
async def replace_all(
self,
rows: list[dict[str, Any]],
*,
updated_by: str | None = None,
) -> list[dict[str, Any]]:
# Last duplicate question wins, preventing a unique-hash violation even
# if a non-UI caller submits malformed data.
by_id: dict[str, dict[str, Any]] = {}
for row in rows:
question = row.get("question")
row_id = row.get("id")
if row_id and question not in (None, ""):
by_id[str(row_id)] = row
by_question = {str(row["question"]): row for row in by_id.values()}
async with self._sf() as session:
await session.execute(delete(FixedQuestionRow))
new_rows = [_row_from(row, updated_by) for row in by_question.values()]
session.add_all(new_rows)
await session.commit()
return [_to_dict(row) for row in new_rows]
@@ -22,7 +22,15 @@ class LlmWikiStore(ABC):
async def get_by_weknora_id(self, weknora_id: str) -> dict[str, Any] | None: ...
@abstractmethod
async def update_mapping(self, mapping_id: str, *, name: str | None = None, description: str | None = None) -> dict[str, Any] | None: ...
async def update_mapping(
self,
mapping_id: str,
*,
name: str | None = None,
description: str | None = None,
wiki_index_enabled: bool | None = None,
external_search_enabled: bool | None = None,
) -> dict[str, Any] | None: ...
@abstractmethod
async def adopt_conversation_deposit_mapping(
@@ -33,6 +33,9 @@ class MemoryLlmWikiStore(LlmWikiStore):
"published_by": None,
"reviewed_by": None,
"published_at": None,
"wiki_index_enabled": True,
"external_search_enabled": False,
"external_search_updated_at": None,
"created_at": now,
"updated_at": now,
}
@@ -65,7 +68,15 @@ class MemoryLlmWikiStore(LlmWikiStore):
return dict(row)
return None
async def update_mapping(self, mapping_id: str, *, name: str | None = None, description: str | None = None) -> dict[str, Any] | None:
async def update_mapping(
self,
mapping_id: str,
*,
name: str | None = None,
description: str | None = None,
wiki_index_enabled: bool | None = None,
external_search_enabled: bool | None = None,
) -> dict[str, Any] | None:
row = self._rows.get(mapping_id)
if row is None:
return None
@@ -73,6 +84,11 @@ class MemoryLlmWikiStore(LlmWikiStore):
row["name"] = name
if description is not None:
row["description"] = description
if wiki_index_enabled is not None:
row["wiki_index_enabled"] = wiki_index_enabled
if external_search_enabled is not None:
row["external_search_enabled"] = external_search_enabled
row["external_search_updated_at"] = _now()
row["updated_at"] = _now()
return dict(row)
@@ -143,11 +159,7 @@ class MemoryLlmWikiStore(LlmWikiStore):
async def get_conversation_deposit_mapping(self) -> dict[str, Any] | None:
for row in self._rows.values():
if (
row.get("owner_user_id") == "system"
and row.get("name") == "对话沉淀"
and row.get("publication_status") == "published"
):
if row.get("owner_user_id") == "system" and row.get("name") == "对话沉淀" and row.get("publication_status") == "published":
return dict(row)
return None
@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import String, UniqueConstraint
from sqlalchemy import Boolean, String, UniqueConstraint, false, true
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
@@ -24,6 +24,9 @@ class LlmWikiKnowledgeBaseRow(Base):
published_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
reviewed_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
published_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
wiki_index_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=true(), index=True)
external_search_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=false(), index=True)
external_search_updated_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -19,7 +19,7 @@ def _now() -> datetime:
def _to_dict(row: LlmWikiKnowledgeBaseRow | LlmWikiConversationDepositRow) -> dict[str, Any]:
data = row.to_dict()
for key in ("published_at", "created_at", "updated_at"):
for key in ("published_at", "external_search_updated_at", "created_at", "updated_at"):
value = data.get(key)
if isinstance(value, datetime):
data[key] = value.isoformat()
@@ -82,7 +82,15 @@ class LlmWikiRepository(LlmWikiStore):
row = (await session.execute(stmt)).scalar_one_or_none()
return _to_dict(row) if row else None
async def update_mapping(self, mapping_id: str, *, name: str | None = None, description: str | None = None) -> dict[str, Any] | None:
async def update_mapping(
self,
mapping_id: str,
*,
name: str | None = None,
description: str | None = None,
wiki_index_enabled: bool | None = None,
external_search_enabled: bool | None = None,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(LlmWikiKnowledgeBaseRow, mapping_id)
if row is None:
@@ -91,6 +99,11 @@ class LlmWikiRepository(LlmWikiStore):
row.name = name
if description is not None:
row.description = description
if wiki_index_enabled is not None:
row.wiki_index_enabled = wiki_index_enabled
if external_search_enabled is not None:
row.external_search_enabled = external_search_enabled
row.external_search_updated_at = _now()
row.updated_at = _now()
await session.commit()
return _to_dict(row)
@@ -0,0 +1,21 @@
"""Persistence facade for the DeerFlow-local Wiki vector index."""
from deerflow.persistence.llmwiki_index.base import LlmWikiIndexStore
from deerflow.persistence.llmwiki_index.memory import MemoryLlmWikiIndexStore
from deerflow.persistence.llmwiki_index.model import LlmWikiWikiPageRow, LlmWikiWikiSyncStateRow, LlmWikiWikiVectorRow
from deerflow.persistence.llmwiki_index.sql import SqlLlmWikiIndexStore
def make_llmwiki_index_store(session_factory):
return MemoryLlmWikiIndexStore() if session_factory is None else SqlLlmWikiIndexStore(session_factory)
__all__ = [
"LlmWikiIndexStore",
"MemoryLlmWikiIndexStore",
"SqlLlmWikiIndexStore",
"LlmWikiWikiPageRow",
"LlmWikiWikiVectorRow",
"LlmWikiWikiSyncStateRow",
"make_llmwiki_index_store",
]
@@ -0,0 +1,54 @@
"""Storage contract for local Wiki pages, vectors, revisions and sync leases."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class LlmWikiIndexStore(ABC):
@abstractmethod
async def get_page_by_slug_hash(self, mapping_id: str, slug_hash: str) -> dict[str, Any] | None: ...
@abstractmethod
async def get_page(self, mapping_id: str, slug: str) -> dict[str, Any] | None: ...
@abstractmethod
async def touch_page(self, page_id: str, sync_id: str) -> None: ...
@abstractmethod
async def replace_page_vectors(
self,
*,
page: dict[str, Any],
vectors: list[dict[str, Any]],
fingerprint: str,
sync_id: str,
) -> dict[str, Any]: ...
@abstractmethod
async def record_page_failure(self, *, page: dict[str, Any], sync_id: str, error: str) -> None: ...
@abstractmethod
async def mark_missing_pages_deleted(self, mapping_id: str, sync_id: str) -> int: ...
@abstractmethod
async def load_vector_snapshot(self, mapping_id: str, fingerprint: str) -> dict[str, Any]: ...
@abstractmethod
async def get_index_revision(self, mapping_id: str) -> tuple[int, str | None, str]: ...
@abstractmethod
async def try_acquire_sync_lease(self, mapping_id: str, *, owner: str, sync_id: str, lease_seconds: int, fingerprint: str) -> bool: ...
@abstractmethod
async def renew_sync_lease(self, mapping_id: str, *, owner: str, lease_seconds: int) -> bool: ...
@abstractmethod
async def finish_sync(self, mapping_id: str, *, owner: str, sync_id: str, success: bool, remote_page_count: int, error: str | None = None) -> None: ...
@abstractmethod
async def list_index_status(self, mapping_ids: list[str] | None = None) -> list[dict[str, Any]]: ...
@abstractmethod
async def mark_rebuild_required(self, mapping_ids: list[str], fingerprint: str) -> int: ...
@@ -0,0 +1,160 @@
"""In-memory local Wiki index store used by tests and DB-less mode."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import uuid4
from deerflow.persistence.llmwiki_index.base import LlmWikiIndexStore
def _now() -> datetime:
return datetime.now(UTC)
class MemoryLlmWikiIndexStore(LlmWikiIndexStore):
def __init__(self) -> None:
self.pages: dict[str, dict[str, Any]] = {}
self.vectors: dict[str, list[dict[str, Any]]] = {}
self.states: dict[str, dict[str, Any]] = {}
def _state(self, mapping_id: str) -> dict[str, Any]:
return self.states.setdefault(mapping_id, {"knowledge_base_mapping_id": mapping_id, "state": "idle", "index_revision": 0, "embedding_fingerprint": None})
async def get_page_by_slug_hash(self, mapping_id: str, slug_hash: str) -> dict[str, Any] | None:
row = next((p for p in self.pages.values() if p["knowledge_base_mapping_id"] == mapping_id and p["slug_hash"] == slug_hash), None)
return dict(row) if row else None
async def get_page(self, mapping_id: str, slug: str) -> dict[str, Any] | None:
row = next((p for p in self.pages.values() if p["knowledge_base_mapping_id"] == mapping_id and p["slug"] == slug), None)
return dict(row) if row else None
async def touch_page(self, page_id: str, sync_id: str) -> None:
if page_id in self.pages:
self.pages[page_id]["last_seen_sync_id"] = sync_id
self.pages[page_id]["last_error"] = None
async def replace_page_vectors(self, *, page: dict[str, Any], vectors: list[dict[str, Any]], fingerprint: str, sync_id: str) -> dict[str, Any]:
existing = await self.get_page_by_slug_hash(page["knowledge_base_mapping_id"], page["slug_hash"])
page_id = existing["id"] if existing else str(uuid4())
now = _now().isoformat()
row = {
**(existing or {}),
**page,
"id": page_id,
"index_status": "ready",
"indexed_fingerprint": fingerprint,
"indexed_at": now,
"last_error": None,
"is_remote_deleted": False,
"last_seen_sync_id": sync_id,
"created_at": (existing or {}).get("created_at", now),
"updated_at": now,
}
self.pages[page_id] = row
self.vectors[page_id] = [{**item, "page_id": page_id, "knowledge_base_mapping_id": page["knowledge_base_mapping_id"], "embedding_fingerprint": fingerprint} for item in vectors]
state = self._state(page["knowledge_base_mapping_id"])
state["index_revision"] += 1
state["embedding_fingerprint"] = fingerprint
return dict(row)
async def record_page_failure(self, *, page: dict[str, Any], sync_id: str, error: str) -> None:
existing = await self.get_page_by_slug_hash(page["knowledge_base_mapping_id"], page["slug_hash"])
if existing:
self.pages[existing["id"]].update(
{
"last_seen_sync_id": sync_id,
"last_error": error,
# Preserve the last atomically completed generation.
"index_status": "ready" if self.vectors.get(existing["id"]) else "failed",
}
)
return
page_id = str(uuid4())
self.pages[page_id] = {**page, "id": page_id, "index_status": "failed", "last_error": error, "last_seen_sync_id": sync_id, "is_remote_deleted": False}
async def mark_missing_pages_deleted(self, mapping_id: str, sync_id: str) -> int:
changed = 0
for page in self.pages.values():
if page["knowledge_base_mapping_id"] == mapping_id and page.get("last_seen_sync_id") != sync_id and not page.get("is_remote_deleted"):
page.update({"is_remote_deleted": True, "index_status": "deleted"})
self.vectors.pop(page["id"], None)
changed += 1
if changed:
self._state(mapping_id)["index_revision"] += 1
return changed
async def load_vector_snapshot(self, mapping_id: str, fingerprint: str) -> dict[str, Any]:
rows = []
for page_id, vectors in self.vectors.items():
page = self.pages[page_id]
if page["knowledge_base_mapping_id"] != mapping_id or page.get("is_remote_deleted") or page.get("status") not in {"published", "draft"} or page.get("index_status") != "ready":
continue
rows.extend({**vector, "page": dict(page)} for vector in vectors if vector["embedding_fingerprint"] == fingerprint)
state = self._state(mapping_id)
return {"revision": state["index_revision"], "fingerprint": fingerprint, "rows": rows}
async def get_index_revision(self, mapping_id: str) -> tuple[int, str | None, str]:
state = self._state(mapping_id)
return int(state["index_revision"]), state.get("embedding_fingerprint"), str(state.get("state") or "idle")
async def try_acquire_sync_lease(self, mapping_id: str, *, owner: str, sync_id: str, lease_seconds: int, fingerprint: str) -> bool:
state = self._state(mapping_id)
now = _now()
expires = state.get("lease_expires_at")
if state.get("lease_owner") is not None and isinstance(expires, datetime) and expires >= now:
return False
state.update(
{
"state": "rebuilding" if state.get("state") == "rebuild_required" else "running",
"active_sync_id": sync_id,
"lease_owner": owner,
"lease_expires_at": now + timedelta(seconds=lease_seconds),
"last_started_at": now,
}
)
return True
async def renew_sync_lease(self, mapping_id: str, *, owner: str, lease_seconds: int) -> bool:
state = self._state(mapping_id)
if state.get("lease_owner") != owner:
return False
state["lease_expires_at"] = _now() + timedelta(seconds=lease_seconds)
return True
async def finish_sync(self, mapping_id: str, *, owner: str, sync_id: str, success: bool, remote_page_count: int, error: str | None = None) -> None:
state = self._state(mapping_id)
if state.get("lease_owner") != owner or state.get("active_sync_id") != sync_id:
return
pages = [p for p in self.pages.values() if p["knowledge_base_mapping_id"] == mapping_id and not p.get("is_remote_deleted")]
was_rebuilding = state.get("state") == "rebuilding"
state.update(
{
"state": "partial" if success and error else "idle" if success else "rebuild_required" if was_rebuilding else "failed",
"lease_owner": None,
"lease_expires_at": None,
"active_sync_id": None,
"last_error": error,
"remote_page_count": remote_page_count,
"local_page_count": len(pages),
"ready_page_count": sum(p.get("index_status") == "ready" for p in pages),
"failed_page_count": sum(p.get("index_status") in {"failed", "stale"} or p.get("last_error") is not None for p in pages),
"vector_count": sum(len(self.vectors.get(p["id"], [])) for p in pages),
"last_completed_at": _now() if success and not error else state.get("last_completed_at"),
"last_success_at": _now() if success else state.get("last_success_at"),
}
)
async def list_index_status(self, mapping_ids: list[str] | None = None) -> list[dict[str, Any]]:
wanted = set(mapping_ids or [])
return [dict(value) for key, value in self.states.items() if not wanted or key in wanted]
async def mark_rebuild_required(self, mapping_ids: list[str], fingerprint: str) -> int:
for mapping_id in mapping_ids:
state = self._state(mapping_id)
state.update({"state": "rebuild_required", "embedding_fingerprint": fingerprint})
for page in self.pages.values():
if page["knowledge_base_mapping_id"] == mapping_id and not page.get("is_remote_deleted"):
page["index_status"] = "stale"
return len(mapping_ids)
@@ -0,0 +1,94 @@
"""Portable ORM rows for the DeerFlow-local WeKnora Wiki mirror/index."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import BigInteger, Boolean, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint, false
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON, PortableLongText
def _now() -> datetime:
return datetime.now(UTC)
class LlmWikiWikiPageRow(Base):
__tablename__ = "llmwiki_wiki_pages"
__table_args__ = (
UniqueConstraint("knowledge_base_mapping_id", "slug_hash", name="uq_llmwiki_wiki_page_mapping_slug_hash"),
Index("ix_llmwiki_wiki_page_mapping_visibility", "knowledge_base_mapping_id", "status", "index_status", "is_remote_deleted"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True)
knowledge_base_mapping_id: Mapped[str] = mapped_column(String(36), ForeignKey("llmwiki_knowledge_bases.id", ondelete="CASCADE"), nullable=False, index=True)
remote_page_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
slug: Mapped[str] = mapped_column(String(1024), nullable=False)
slug_hash: Mapped[str] = mapped_column(String(64), nullable=False)
title: Mapped[str] = mapped_column(String(512), nullable=False, default="", index=True)
summary: Mapped[str] = mapped_column(PortableLongText(), nullable=False, default="")
content_md: Mapped[str] = mapped_column(PortableLongText(), nullable=False, default="")
page_type: Mapped[str] = mapped_column(String(32), nullable=False, default="article", index=True)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="published", index=True)
aliases: Mapped[list] = mapped_column(PortableJSON(), nullable=False, default=list)
category_path: Mapped[list] = mapped_column(PortableJSON(), nullable=False, default=list)
parent_slug: Mapped[str | None] = mapped_column(String(1024), nullable=True)
wiki_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
source_refs: Mapped[list] = mapped_column(PortableJSON(), nullable=False, default=list)
chunk_refs: Mapped[list] = mapped_column(PortableJSON(), nullable=False, default=list)
page_version: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
remote_created_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
remote_updated_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True, index=True)
index_status: Mapped[str] = mapped_column(String(24), nullable=False, default="pending", index=True)
indexed_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True)
indexed_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
is_remote_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=false(), index=True)
last_seen_sync_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now, onupdate=_now, index=True)
class LlmWikiWikiVectorRow(Base):
__tablename__ = "llmwiki_wiki_vectors"
__table_args__ = (
UniqueConstraint("page_id", "section_index", "embedding_fingerprint", name="uq_llmwiki_wiki_vector_page_section_fingerprint"),
Index("ix_llmwiki_wiki_vector_snapshot", "knowledge_base_mapping_id", "embedding_fingerprint"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True)
page_id: Mapped[str] = mapped_column(String(36), ForeignKey("llmwiki_wiki_pages.id", ondelete="CASCADE"), nullable=False, index=True)
knowledge_base_mapping_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
section_index: Mapped[int] = mapped_column(Integer, nullable=False)
heading: Mapped[str | None] = mapped_column(String(1024), nullable=True)
section_content: Mapped[str] = mapped_column(PortableLongText(), nullable=False)
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
embedding_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
embedding_dimensions: Mapped[int] = mapped_column(Integer, nullable=False)
vector_blob: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
class LlmWikiWikiSyncStateRow(Base):
__tablename__ = "llmwiki_wiki_sync_states"
knowledge_base_mapping_id: Mapped[str] = mapped_column(String(36), ForeignKey("llmwiki_knowledge_bases.id", ondelete="CASCADE"), primary_key=True)
state: Mapped[str] = mapped_column(String(24), nullable=False, default="idle")
active_sync_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
lease_owner: Mapped[str | None] = mapped_column(String(128), nullable=True)
lease_expires_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
last_started_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
last_completed_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
last_success_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
remote_page_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
local_page_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
ready_page_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
vector_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
failed_page_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
index_revision: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
embedding_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now, onupdate=_now)
@@ -0,0 +1,295 @@
"""SQLAlchemy implementation of the local Wiki index store."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import uuid4
from sqlalchemy import case, delete, func, or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.llmwiki_index.base import LlmWikiIndexStore
from deerflow.persistence.llmwiki_index.model import LlmWikiWikiPageRow, LlmWikiWikiSyncStateRow, LlmWikiWikiVectorRow
def _now() -> datetime:
return datetime.now(UTC)
def _dict(row) -> dict[str, Any]:
value = row.to_dict()
for key, item in tuple(value.items()):
if isinstance(item, datetime):
value[key] = item.isoformat()
return value
class SqlLlmWikiIndexStore(LlmWikiIndexStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def _ensure_state(self, session: AsyncSession, mapping_id: str) -> LlmWikiWikiSyncStateRow:
row = await session.get(LlmWikiWikiSyncStateRow, mapping_id)
if row is None:
row = LlmWikiWikiSyncStateRow(knowledge_base_mapping_id=mapping_id, state="idle", index_revision=0, updated_at=_now())
session.add(row)
await session.flush()
return row
async def get_page_by_slug_hash(self, mapping_id: str, slug_hash: str) -> dict[str, Any] | None:
stmt = select(LlmWikiWikiPageRow).where(LlmWikiWikiPageRow.knowledge_base_mapping_id == mapping_id, LlmWikiWikiPageRow.slug_hash == slug_hash)
async with self._sf() as session:
row = (await session.execute(stmt)).scalar_one_or_none()
return _dict(row) if row else None
async def get_page(self, mapping_id: str, slug: str) -> dict[str, Any] | None:
stmt = select(LlmWikiWikiPageRow).where(LlmWikiWikiPageRow.knowledge_base_mapping_id == mapping_id, LlmWikiWikiPageRow.slug == slug)
async with self._sf() as session:
row = (await session.execute(stmt)).scalar_one_or_none()
return _dict(row) if row else None
async def touch_page(self, page_id: str, sync_id: str) -> None:
async with self._sf() as session:
await session.execute(
update(LlmWikiWikiPageRow)
.where(LlmWikiWikiPageRow.id == page_id)
.values(last_seen_sync_id=sync_id, last_error=None, updated_at=_now())
)
await session.commit()
async def replace_page_vectors(self, *, page: dict[str, Any], vectors: list[dict[str, Any]], fingerprint: str, sync_id: str) -> dict[str, Any]:
mapping_id = str(page["knowledge_base_mapping_id"])
async with self._sf() as session:
stmt = select(LlmWikiWikiPageRow).where(LlmWikiWikiPageRow.knowledge_base_mapping_id == mapping_id, LlmWikiWikiPageRow.slug_hash == page["slug_hash"]).with_for_update()
row = (await session.execute(stmt)).scalar_one_or_none()
if row is not None and row.slug != page["slug"]:
raise ValueError("Wiki slug hash collision detected")
now = _now()
if row is None:
row = LlmWikiWikiPageRow(id=str(uuid4()), created_at=now, **page)
session.add(row)
else:
for key, value in page.items():
setattr(row, key, value)
row.index_status = "ready"
row.indexed_fingerprint = fingerprint
row.indexed_at = now
row.last_error = None
row.is_remote_deleted = False
row.last_seen_sync_id = sync_id
row.updated_at = now
await session.flush()
await session.execute(delete(LlmWikiWikiVectorRow).where(LlmWikiWikiVectorRow.page_id == row.id))
session.add_all(
[
LlmWikiWikiVectorRow(
id=str(uuid4()),
page_id=row.id,
knowledge_base_mapping_id=mapping_id,
section_index=int(item["section_index"]),
heading=item.get("heading"),
section_content=str(item["section_content"]),
content_hash=str(item["content_hash"]),
embedding_fingerprint=fingerprint,
embedding_dimensions=int(item["embedding_dimensions"]),
vector_blob=bytes(item["vector_blob"]),
created_at=now,
)
for item in vectors
]
)
state = await self._ensure_state(session, mapping_id)
state.index_revision = int(state.index_revision or 0) + 1
state.embedding_fingerprint = fingerprint
state.updated_at = now
await session.commit()
return _dict(row)
async def record_page_failure(self, *, page: dict[str, Any], sync_id: str, error: str) -> None:
mapping_id = str(page["knowledge_base_mapping_id"])
safe_error = error[:4000]
async with self._sf() as session:
stmt = select(LlmWikiWikiPageRow).where(LlmWikiWikiPageRow.knowledge_base_mapping_id == mapping_id, LlmWikiWikiPageRow.slug_hash == page["slug_hash"])
row = (await session.execute(stmt)).scalar_one_or_none()
now = _now()
if row is None:
row = LlmWikiWikiPageRow(id=str(uuid4()), created_at=now, **page)
session.add(row)
row.index_status = "failed"
else:
vector_count = int((await session.execute(select(func.count()).select_from(LlmWikiWikiVectorRow).where(LlmWikiWikiVectorRow.page_id == row.id))).scalar_one())
# Preserve the last atomically completed generation while the
# newer remote version remains pending after an embedding error.
row.index_status = "ready" if vector_count else "failed"
row.last_seen_sync_id = sync_id
row.last_error = safe_error
row.updated_at = now
await self._ensure_state(session, mapping_id)
await session.commit()
async def mark_missing_pages_deleted(self, mapping_id: str, sync_id: str) -> int:
async with self._sf() as session:
stmt = (
select(LlmWikiWikiPageRow)
.where(
LlmWikiWikiPageRow.knowledge_base_mapping_id == mapping_id,
LlmWikiWikiPageRow.is_remote_deleted.is_(False),
or_(LlmWikiWikiPageRow.last_seen_sync_id.is_(None), LlmWikiWikiPageRow.last_seen_sync_id != sync_id),
)
.with_for_update()
)
rows = list((await session.execute(stmt)).scalars())
if not rows:
return 0
now = _now()
page_ids = [row.id for row in rows]
await session.execute(delete(LlmWikiWikiVectorRow).where(LlmWikiWikiVectorRow.page_id.in_(page_ids)))
for row in rows:
row.is_remote_deleted = True
row.index_status = "deleted"
row.updated_at = now
state = await self._ensure_state(session, mapping_id)
state.index_revision = int(state.index_revision or 0) + 1
state.updated_at = now
await session.commit()
return len(rows)
async def load_vector_snapshot(self, mapping_id: str, fingerprint: str) -> dict[str, Any]:
stmt = (
select(LlmWikiWikiVectorRow, LlmWikiWikiPageRow)
.join(LlmWikiWikiPageRow, LlmWikiWikiPageRow.id == LlmWikiWikiVectorRow.page_id)
.where(
LlmWikiWikiVectorRow.knowledge_base_mapping_id == mapping_id,
LlmWikiWikiVectorRow.embedding_fingerprint == fingerprint,
LlmWikiWikiPageRow.index_status == "ready",
LlmWikiWikiPageRow.is_remote_deleted.is_(False),
LlmWikiWikiPageRow.status.in_(["published", "draft"]),
)
.order_by(LlmWikiWikiVectorRow.page_id, LlmWikiWikiVectorRow.section_index)
)
async with self._sf() as session:
state = await session.get(LlmWikiWikiSyncStateRow, mapping_id)
pairs = list((await session.execute(stmt)).all())
rows = [{**_dict(vector), "page": _dict(page)} for vector, page in pairs]
return {"revision": int(state.index_revision if state else 0), "fingerprint": fingerprint, "rows": rows}
async def get_index_revision(self, mapping_id: str) -> tuple[int, str | None, str]:
async with self._sf() as session:
state = await session.get(LlmWikiWikiSyncStateRow, mapping_id)
if state is None:
return 0, None, "not_synced"
return int(state.index_revision or 0), state.embedding_fingerprint, state.state
async def try_acquire_sync_lease(self, mapping_id: str, *, owner: str, sync_id: str, lease_seconds: int, fingerprint: str) -> bool:
now = _now()
async with self._sf() as session:
try:
await self._ensure_state(session, mapping_id)
await session.commit()
except IntegrityError:
# Another worker created the one-per-KB state row between our
# read and insert. Roll back this isolated bootstrap session;
# the conditional UPDATE below is the actual lease election.
await session.rollback()
async with self._sf() as session:
stmt = (
update(LlmWikiWikiSyncStateRow)
.where(
LlmWikiWikiSyncStateRow.knowledge_base_mapping_id == mapping_id,
or_(
LlmWikiWikiSyncStateRow.lease_owner.is_(None),
LlmWikiWikiSyncStateRow.lease_expires_at.is_(None),
LlmWikiWikiSyncStateRow.lease_expires_at < now,
),
)
.values(
state=case((LlmWikiWikiSyncStateRow.state == "rebuild_required", "rebuilding"), else_="running"),
active_sync_id=sync_id,
lease_owner=owner,
lease_expires_at=now + timedelta(seconds=lease_seconds),
last_started_at=now,
last_error=None,
updated_at=now,
)
)
result = await session.execute(stmt)
await session.commit()
return bool(result.rowcount)
async def renew_sync_lease(self, mapping_id: str, *, owner: str, lease_seconds: int) -> bool:
now = _now()
async with self._sf() as session:
result = await session.execute(
update(LlmWikiWikiSyncStateRow)
.where(LlmWikiWikiSyncStateRow.knowledge_base_mapping_id == mapping_id, LlmWikiWikiSyncStateRow.lease_owner == owner)
.values(lease_expires_at=now + timedelta(seconds=lease_seconds), updated_at=now)
)
await session.commit()
return bool(result.rowcount)
async def finish_sync(self, mapping_id: str, *, owner: str, sync_id: str, success: bool, remote_page_count: int, error: str | None = None) -> None:
async with self._sf() as session:
state = await session.get(LlmWikiWikiSyncStateRow, mapping_id)
if state is None or state.lease_owner != owner or state.active_sync_id != sync_id:
return
visible = [LlmWikiWikiPageRow.knowledge_base_mapping_id == mapping_id, LlmWikiWikiPageRow.is_remote_deleted.is_(False)]
local_count = int((await session.execute(select(func.count()).select_from(LlmWikiWikiPageRow).where(*visible))).scalar_one())
ready_count = int((await session.execute(select(func.count()).select_from(LlmWikiWikiPageRow).where(*visible, LlmWikiWikiPageRow.index_status == "ready"))).scalar_one())
failed_count = int(
(
await session.execute(
select(func.count())
.select_from(LlmWikiWikiPageRow)
.where(
*visible,
or_(
LlmWikiWikiPageRow.index_status.in_(["failed", "stale"]),
LlmWikiWikiPageRow.last_error.is_not(None),
),
)
)
).scalar_one()
)
vector_count = int((await session.execute(select(func.count()).select_from(LlmWikiWikiVectorRow).where(LlmWikiWikiVectorRow.knowledge_base_mapping_id == mapping_id))).scalar_one())
now = _now()
was_rebuilding = state.state == "rebuilding"
state.state = "partial" if success and error else "idle" if success else "rebuild_required" if was_rebuilding else "failed"
state.active_sync_id = None
state.lease_owner = None
state.lease_expires_at = None
state.last_error = (error or "")[:4000] or None
state.remote_page_count = remote_page_count
state.local_page_count = local_count
state.ready_page_count = ready_count
state.failed_page_count = failed_count
state.vector_count = vector_count
if success and not error:
state.last_completed_at = now
if success:
state.last_success_at = now
state.updated_at = now
await session.commit()
async def list_index_status(self, mapping_ids: list[str] | None = None) -> list[dict[str, Any]]:
stmt = select(LlmWikiWikiSyncStateRow)
if mapping_ids:
stmt = stmt.where(LlmWikiWikiSyncStateRow.knowledge_base_mapping_id.in_(mapping_ids))
stmt = stmt.order_by(LlmWikiWikiSyncStateRow.updated_at.desc())
async with self._sf() as session:
return [_dict(row) for row in (await session.execute(stmt)).scalars()]
async def mark_rebuild_required(self, mapping_ids: list[str], fingerprint: str) -> int:
if not mapping_ids:
return 0
now = _now()
async with self._sf() as session:
for mapping_id in mapping_ids:
state = await self._ensure_state(session, mapping_id)
state.state = "rebuild_required"
state.embedding_fingerprint = fingerprint
state.updated_at = now
await session.execute(update(LlmWikiWikiPageRow).where(LlmWikiWikiPageRow.knowledge_base_mapping_id.in_(mapping_ids), LlmWikiWikiPageRow.is_remote_deleted.is_(False)).values(index_status="stale", updated_at=now))
await session.commit()
return len(mapping_ids)
@@ -0,0 +1,51 @@
"""Create administrator-managed exact-match fixed questions.
Revision ID: 20260821_01
Revises: 20260816_01
Create Date: 2026-08-21
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import mysql
revision = "20260821_01"
down_revision = "20260816_01"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
if sa.inspect(bind).has_table("fixed_questions"):
return
long_text = sa.Text().with_variant(mysql.LONGTEXT(), "mysql")
op.create_table(
"fixed_questions",
sa.Column("id", sa.String(length=64), primary_key=True),
sa.Column("question", sa.Text(), nullable=False),
sa.Column("question_hash", sa.String(length=64), nullable=False),
sa.Column("answer", long_text, nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("updated_by", sa.String(length=64), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint(
"question_hash",
name="uq_fixed_questions_question_hash",
),
)
op.create_index(
"ix_fixed_questions_question_hash",
"fixed_questions",
["question_hash"],
unique=False,
)
def downgrade() -> None:
bind = op.get_bind()
if sa.inspect(bind).has_table("fixed_questions"):
op.drop_table("fixed_questions")
@@ -0,0 +1,44 @@
"""Add configurable fixed-answer streaming speed.
Revision ID: 20260821_02
Revises: 20260821_01
Create Date: 2026-08-21
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "20260821_02"
down_revision = "20260821_01"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table("fixed_questions"):
return
columns = {column["name"] for column in inspector.get_columns("fixed_questions")}
if "tokens_per_second" not in columns:
op.add_column(
"fixed_questions",
sa.Column(
"tokens_per_second",
sa.Integer(),
nullable=False,
server_default="100",
),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table("fixed_questions"):
return
columns = {column["name"] for column in inspector.get_columns("fixed_questions")}
if "tokens_per_second" in columns:
op.drop_column("fixed_questions", "tokens_per_second")
@@ -0,0 +1,143 @@
"""Add DeerFlow-local WeKnora Wiki mirror and vector index.
Revision ID: 20260823_01
Revises: 20260821_02
Create Date: 2026-08-23
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import mysql
revision = "20260823_01"
down_revision = "20260821_02"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {column["name"] for column in inspector.get_columns("llmwiki_knowledge_bases")}
if "wiki_index_enabled" not in columns:
op.add_column("llmwiki_knowledge_bases", sa.Column("wiki_index_enabled", sa.Boolean(), nullable=False, server_default=sa.true()))
if "external_search_enabled" not in columns:
op.add_column("llmwiki_knowledge_bases", sa.Column("external_search_enabled", sa.Boolean(), nullable=False, server_default=sa.false()))
if "external_search_updated_at" not in columns:
op.add_column("llmwiki_knowledge_bases", sa.Column("external_search_updated_at", sa.DateTime(timezone=True), nullable=True))
mapping_indexes = {index["name"] for index in inspector.get_indexes("llmwiki_knowledge_bases")}
if "ix_llmwiki_knowledge_bases_wiki_index_enabled" not in mapping_indexes:
op.create_index("ix_llmwiki_knowledge_bases_wiki_index_enabled", "llmwiki_knowledge_bases", ["wiki_index_enabled"])
if "ix_llmwiki_knowledge_bases_external_search_enabled" not in mapping_indexes:
op.create_index("ix_llmwiki_knowledge_bases_external_search_enabled", "llmwiki_knowledge_bases", ["external_search_enabled"])
long_text = sa.Text().with_variant(mysql.LONGTEXT(), "mysql")
portable_json = sa.JSON().with_variant(sa.Text(), "mysql")
if not inspector.has_table("llmwiki_wiki_pages"):
op.create_table(
"llmwiki_wiki_pages",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("knowledge_base_mapping_id", sa.String(36), sa.ForeignKey("llmwiki_knowledge_bases.id", ondelete="CASCADE"), nullable=False),
sa.Column("remote_page_id", sa.String(128), nullable=True),
sa.Column("slug", sa.String(1024), nullable=False),
sa.Column("slug_hash", sa.String(64), nullable=False),
sa.Column("title", sa.String(512), nullable=False, server_default=""),
sa.Column("summary", long_text, nullable=False),
sa.Column("content_md", long_text, nullable=False),
sa.Column("page_type", sa.String(32), nullable=False, server_default="article"),
sa.Column("status", sa.String(32), nullable=False, server_default="published"),
sa.Column("aliases", portable_json, nullable=False),
sa.Column("category_path", portable_json, nullable=False),
sa.Column("parent_slug", sa.String(1024), nullable=True),
sa.Column("wiki_path", sa.String(1024), nullable=True),
sa.Column("source_refs", portable_json, nullable=False),
sa.Column("chunk_refs", portable_json, nullable=False),
sa.Column("page_version", sa.Integer(), nullable=False, server_default="0"),
sa.Column("content_hash", sa.String(64), nullable=False),
sa.Column("remote_created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("remote_updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("index_status", sa.String(24), nullable=False, server_default="pending"),
sa.Column("indexed_fingerprint", sa.String(64), nullable=True),
sa.Column("indexed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("is_remote_deleted", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("last_seen_sync_id", sa.String(36), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("knowledge_base_mapping_id", "slug_hash", name="uq_llmwiki_wiki_page_mapping_slug_hash"),
)
op.create_index("ix_llmwiki_wiki_pages_knowledge_base_mapping_id", "llmwiki_wiki_pages", ["knowledge_base_mapping_id"])
op.create_index("ix_llmwiki_wiki_page_mapping_visibility", "llmwiki_wiki_pages", ["knowledge_base_mapping_id", "status", "index_status", "is_remote_deleted"])
op.create_index("ix_llmwiki_wiki_pages_remote_page_id", "llmwiki_wiki_pages", ["remote_page_id"])
op.create_index("ix_llmwiki_wiki_pages_title", "llmwiki_wiki_pages", ["title"])
op.create_index("ix_llmwiki_wiki_pages_page_type", "llmwiki_wiki_pages", ["page_type"])
op.create_index("ix_llmwiki_wiki_pages_status", "llmwiki_wiki_pages", ["status"])
op.create_index("ix_llmwiki_wiki_pages_page_version", "llmwiki_wiki_pages", ["page_version"])
op.create_index("ix_llmwiki_wiki_pages_content_hash", "llmwiki_wiki_pages", ["content_hash"])
op.create_index("ix_llmwiki_wiki_pages_remote_updated_at", "llmwiki_wiki_pages", ["remote_updated_at"])
op.create_index("ix_llmwiki_wiki_pages_index_status", "llmwiki_wiki_pages", ["index_status"])
op.create_index("ix_llmwiki_wiki_pages_is_remote_deleted", "llmwiki_wiki_pages", ["is_remote_deleted"])
op.create_index("ix_llmwiki_wiki_pages_last_seen_sync_id", "llmwiki_wiki_pages", ["last_seen_sync_id"])
op.create_index("ix_llmwiki_wiki_pages_updated_at", "llmwiki_wiki_pages", ["updated_at"])
if not inspector.has_table("llmwiki_wiki_vectors"):
op.create_table(
"llmwiki_wiki_vectors",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("page_id", sa.String(36), sa.ForeignKey("llmwiki_wiki_pages.id", ondelete="CASCADE"), nullable=False),
sa.Column("knowledge_base_mapping_id", sa.String(36), nullable=False),
sa.Column("section_index", sa.Integer(), nullable=False),
sa.Column("heading", sa.String(1024), nullable=True),
sa.Column("section_content", long_text, nullable=False),
sa.Column("content_hash", sa.String(64), nullable=False),
sa.Column("embedding_fingerprint", sa.String(64), nullable=False),
sa.Column("embedding_dimensions", sa.Integer(), nullable=False),
sa.Column("vector_blob", sa.LargeBinary(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("page_id", "section_index", "embedding_fingerprint", name="uq_llmwiki_wiki_vector_page_section_fingerprint"),
)
op.create_index("ix_llmwiki_wiki_vector_snapshot", "llmwiki_wiki_vectors", ["knowledge_base_mapping_id", "embedding_fingerprint"])
op.create_index("ix_llmwiki_wiki_vectors_page_id", "llmwiki_wiki_vectors", ["page_id"])
op.create_index("ix_llmwiki_wiki_vectors_knowledge_base_mapping_id", "llmwiki_wiki_vectors", ["knowledge_base_mapping_id"])
op.create_index("ix_llmwiki_wiki_vectors_content_hash", "llmwiki_wiki_vectors", ["content_hash"])
op.create_index("ix_llmwiki_wiki_vectors_embedding_fingerprint", "llmwiki_wiki_vectors", ["embedding_fingerprint"])
if not inspector.has_table("llmwiki_wiki_sync_states"):
op.create_table(
"llmwiki_wiki_sync_states",
sa.Column("knowledge_base_mapping_id", sa.String(36), sa.ForeignKey("llmwiki_knowledge_bases.id", ondelete="CASCADE"), primary_key=True),
sa.Column("state", sa.String(24), nullable=False, server_default="idle"),
sa.Column("active_sync_id", sa.String(36), nullable=True),
sa.Column("lease_owner", sa.String(128), nullable=True),
sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("remote_page_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("local_page_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("ready_page_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("vector_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("failed_page_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("index_revision", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column("embedding_fingerprint", sa.String(64), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
for table in ("llmwiki_wiki_vectors", "llmwiki_wiki_pages", "llmwiki_wiki_sync_states"):
if inspector.has_table(table):
op.drop_table(table)
columns = {column["name"] for column in inspector.get_columns("llmwiki_knowledge_bases")}
indexes = {index["name"] for index in inspector.get_indexes("llmwiki_knowledge_bases")}
for index in ("ix_llmwiki_knowledge_bases_external_search_enabled", "ix_llmwiki_knowledge_bases_wiki_index_enabled"):
if index in indexes:
op.drop_index(index, table_name="llmwiki_knowledge_bases")
for column in ("external_search_updated_at", "external_search_enabled", "wiki_index_enabled"):
if column in columns:
op.drop_column("llmwiki_knowledge_bases", column)
@@ -47,10 +47,12 @@ from deerflow.persistence.deep_research_sources.model import DeepResearchSourceR
from deerflow.persistence.document_rewrite_versions.model import DocumentRewriteVersionRow
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.fixed_questions.model import FixedQuestionRow
from deerflow.persistence.intent_turns.model import IntentTurnRow
from deerflow.persistence.light_apps.model import LightAppRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.llmwiki.model import LlmWikiConversationDepositRow, LlmWikiKnowledgeBaseRow
from deerflow.persistence.llmwiki_index.model import LlmWikiWikiPageRow, LlmWikiWikiSyncStateRow, LlmWikiWikiVectorRow
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.menu_overrides.model import MenuOverrideRow
from deerflow.persistence.models.run_event import RunEventRow
@@ -98,6 +100,7 @@ from deerflow.persistence.user_prompts.model import UserCustomPromptRow
__all__ = [
"FeedbackRow",
"FixedQuestionRow",
"AgentExtrasRow",
"AgentFavoriteRow",
"AgentRow",
@@ -128,6 +131,9 @@ __all__ = [
"LlmCallMetricRow",
"LlmWikiKnowledgeBaseRow",
"LlmWikiConversationDepositRow",
"LlmWikiWikiPageRow",
"LlmWikiWikiVectorRow",
"LlmWikiWikiSyncStateRow",
"MenuOverrideRow",
"NotificationRow",
"ParallelAgentTaskRow",
@@ -6,28 +6,25 @@ import json
import re
from collections.abc import Mapping
from typing import Any
from urllib.parse import urlencode
from langchain.tools import ToolRuntime, tool
from langgraph.typing import ContextT
from deerflow.agents.thread_state import ThreadState
from deerflow.config import get_app_config
from deerflow.config.agents_config import load_agent_config
from deerflow.integrations.weknora.client import WeKnoraError
from deerflow.integrations.weknora.runtime import build_weknora_client, get_llmwiki_store, get_resolved_llmwiki_runtime
from deerflow.integrations.weknora.local_index.runtime import get_local_wiki_retrieval_service
from deerflow.integrations.weknora.local_index.search import WikiIndexError
from deerflow.integrations.weknora.runtime import get_llmwiki_store, get_resolved_llmwiki_runtime
from deerflow.runtime.user_context import get_current_user, get_effective_user_id
_CONVERSATION_DEPOSIT_NAME = "对话沉淀"
_CONVERSATION_DEPOSIT_OWNER_USER_ID = "system"
_LEGACY_BRAND_PATTERN = re.compile(r"deer[\s_-]*flow", re.IGNORECASE)
def _is_conversation_deposit_mapping(row: Mapping[str, Any]) -> bool:
return (
str(row.get("owner_user_id") or "") == _CONVERSATION_DEPOSIT_OWNER_USER_ID
and str(row.get("name") or "").strip() == _CONVERSATION_DEPOSIT_NAME
)
return str(row.get("owner_user_id") or "") == _CONVERSATION_DEPOSIT_OWNER_USER_ID and str(row.get("name") or "").strip() == _CONVERSATION_DEPOSIT_NAME
def _sanitize_conversation_deposit_value(value: Any) -> Any:
@@ -79,9 +76,10 @@ async def llmwiki_search_tool(
query: Focused natural-language retrieval query.
"""
local_config = get_app_config().llmwiki.local_wiki_index
resolved = get_resolved_llmwiki_runtime()
if not resolved.weknora_enabled:
return "LLMWiki retrieval is unavailable because WeKnora mode is not enabled."
if not (local_config.enabled and local_config.internal_search_enabled) and not resolved.weknora_enabled:
return "LLMWiki retrieval is unavailable because no retrieval provider is enabled."
store = get_llmwiki_store()
user_id = get_effective_user_id()
@@ -111,52 +109,26 @@ async def llmwiki_search_tool(
return "No authorized LLMWiki knowledge bases are available."
try:
results = await build_weknora_client(resolved).search(
query.strip(),
[str(row["weknora_id"]) for row in rows],
)
except (RuntimeError, WeKnoraError) as exc:
return f"LLMWiki retrieval failed: {exc}"
local_by_remote = {str(row["weknora_id"]): row for row in rows}
citations = []
for result in results:
mapping = local_by_remote.get(str(result.get("knowledge_base_id") or ""))
if mapping is None:
continue
visible_result = (
_sanitize_conversation_deposit_value(result)
if _is_conversation_deposit_mapping(mapping)
else result
)
params = urlencode(
{
"knowledge_base_id": mapping["id"],
"chunk_id": visible_result.get("chunk_id") or "",
"knowledge_id": visible_result.get("knowledge_id") or "",
"document_id": visible_result.get("knowledge_id") or "",
"wiki_slug": visible_result.get("wiki_slug") or visible_result.get("slug") or "",
}
)
citations.append(
{
"title": visible_result.get("title") or visible_result.get("filename") or mapping.get("name") or "LLMWiki source",
"content": visible_result.get("content") or "",
"score": visible_result.get("score") or 0,
"knowledge_base_id": mapping["id"],
"knowledge_base_name": mapping.get("name") or "",
"source": mapping.get("name") or "LLMWiki",
"knowledge_id": visible_result.get("knowledge_id") or "",
"chunk_id": visible_result.get("chunk_id") or "",
"url": f"/weknora-source-preview?{params}",
}
)
payload = await get_local_wiki_retrieval_service().search(query.strip(), rows)
except (RuntimeError, WikiIndexError) as exc:
code = exc.code if isinstance(exc, WikiIndexError) else "WIKI_RETRIEVAL_UNAVAILABLE"
return f"LLMWiki Wiki-only retrieval failed ({code}); retrieval is unavailable, not empty."
mapping_by_id = {str(row["id"]): row for row in rows}
results = []
for item in payload["results"]:
mapping = mapping_by_id.get(str(item.get("knowledge_base_id") or ""))
visible = _sanitize_conversation_deposit_value(item) if mapping and _is_conversation_deposit_mapping(mapping) else item
results.append(visible)
return json.dumps(
{
"query": query,
"selection": [row["id"] for row in rows],
"knowledge_base_count": len(rows),
"results": citations,
"mode": payload.get("mode") or "wiki_api",
"results": results,
"partial": payload.get("partial", False),
"warnings": payload.get("warnings", []),
"fallback_knowledge_base_ids": payload.get("fallback_knowledge_base_ids", []),
},
ensure_ascii=False,
)
@@ -21,7 +21,8 @@ dependencies = [
"langgraph-cli>=0.4.24",
"langgraph-runtime-inmem>=0.28.0",
"markdownify>=1.2.2",
"markitdown[all,xlsx]>=0.0.1a2",
"markitdown[all,xlsx]>=0.0.1a2",
"numpy>=2.0.0",
"pydantic>=2.12.5",
"pyyaml>=6.0.3",
"readabilipy>=0.3.0",
@@ -24,6 +24,7 @@ dependencies = [
"croniter>=6.2.2",
"hindsight-client>=0.4.22",
"asyncmy>=0.2.11",
"openpyxl>=3.1.5",
]
[project.optional-dependencies]
@@ -0,0 +1,70 @@
from types import SimpleNamespace
from deerflow.agents.lead_agent import prompt as prompt_module
from deerflow.config.skills_config import EsQueryRoutingConfig, SkillsConfig
def _app_config(*, enabled: bool, prompt: str = "自定义 ES 路由规则") -> SimpleNamespace:
return SimpleNamespace(
skills=SkillsConfig(
es_query_routing=EsQueryRoutingConfig(enabled=enabled, prompt=prompt),
)
)
def test_es_query_routing_config_is_disabled_by_default() -> None:
config = SkillsConfig()
assert config.es_query_routing.enabled is False
assert "es_query" in config.es_query_routing.prompt
def test_es_query_routing_section_honors_switch_prompt_and_allowlist() -> None:
enabled = _app_config(enabled=True, prompt=" 自定义 ES 路由规则 ")
assert prompt_module._build_es_query_routing_section(app_config=enabled) == (
'<skill_routing skill="es_query">\n自定义 ES 路由规则\n</skill_routing>'
)
assert (
prompt_module._build_es_query_routing_section(
app_config=enabled,
available_skills={"other_skill"},
)
== ""
)
assert "自定义 ES 路由规则" in prompt_module._build_es_query_routing_section(
app_config=enabled,
available_skills={"es_query"},
)
assert prompt_module._build_es_query_routing_section(app_config=_app_config(enabled=False)) == ""
assert prompt_module._build_es_query_routing_section(app_config=_app_config(enabled=True, prompt=" ")) == ""
def test_es_query_routing_is_injected_into_lead_and_allowed_custom_agent_prompts(monkeypatch) -> None:
config = _app_config(enabled=True)
monkeypatch.setattr(prompt_module, "_get_memory_context", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "get_skills_prompt_section", lambda *args, **kwargs: "<skill_system />")
monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "_build_acp_section", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "_build_custom_mounts_section", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "_current_time_context", lambda: "")
lead_prompt = prompt_module.apply_prompt_template(app_config=config)
allowed_custom_prompt = prompt_module.apply_agent_only_prompt_template(
agent_id="agent-with-es",
agent_name="Agent with ES",
available_skills={"es_query"},
app_config=config,
)
denied_custom_prompt = prompt_module.apply_agent_only_prompt_template(
agent_id="agent-without-es",
agent_name="Agent without ES",
available_skills={"other_skill"},
app_config=config,
)
assert '<skill_routing skill="es_query">' in lead_prompt
assert "自定义 ES 路由规则" in lead_prompt
assert '<skill_routing skill="es_query">' in allowed_custom_prompt
assert '<skill_routing skill="es_query">' not in denied_custom_prompt
@@ -0,0 +1,51 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from app.gateway.auth_middleware import _authorize_external_llmwiki
from app.gateway.routers.external_llmwiki import _external_mappings
def _request(key: str) -> Request:
return Request({"type": "http", "method": "POST", "path": "/api/external/llmwiki/wiki/vector-search", "headers": [(b"x-api-key", key.encode())]})
def test_external_api_key_uses_isolated_fail_closed_auth(monkeypatch) -> None:
config = SimpleNamespace(enabled=True, api_key="current", previous_api_key="previous")
monkeypatch.setattr("deerflow.config.get_app_config", lambda: SimpleNamespace(llmwiki=SimpleNamespace(local_wiki_index=SimpleNamespace(external_api=config))))
assert _authorize_external_llmwiki(_request("current")) is None
assert _authorize_external_llmwiki(_request("previous")) is None
rejected = _authorize_external_llmwiki(_request("wrong"))
assert rejected is not None
assert rejected.status_code == 401
@pytest.mark.asyncio
async def test_external_mappings_require_explicit_public_external_permission() -> None:
rows = [
{"id": "allowed", "publication_status": "published", "external_search_enabled": True, "wiki_index_enabled": True},
{"id": "private", "publication_status": "private", "external_search_enabled": True, "wiki_index_enabled": True},
{"id": "not-opted-in", "publication_status": "published", "external_search_enabled": False, "wiki_index_enabled": True},
{
"id": "deposit",
"name": "对话沉淀",
"owner_user_id": "system",
"publication_status": "published",
"external_search_enabled": True,
"wiki_index_enabled": True,
},
]
class Store:
async def list_visible(self, *_args, **_kwargs):
return rows
request = Request({"type": "http", "method": "POST", "path": "/", "headers": [], "app": SimpleNamespace(state=SimpleNamespace(llmwiki_store=Store()))})
assert [row["id"] for row in await _external_mappings(request, [])] == ["allowed"]
with pytest.raises(HTTPException) as exc_info:
await _external_mappings(request, ["not-opted-in"])
assert exc_info.value.status_code == 404
@@ -0,0 +1,288 @@
"""Regression tests for administrator-managed exact-match fixed answers."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from langchain_core.messages import HumanMessage
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.routers.fixed_questions import (
FixedQuestion,
FixedQuestionReplaceRequest,
)
from app.gateway.services import (
RUN_SCOPE_EXTERNAL_ROOT,
RUN_SCOPE_ORCHESTRATION_CHILD,
_find_fixed_answer,
normalize_input,
)
from deerflow.agents import fixed_answer_agent as fixed_answer_module
from deerflow.agents.fixed_answer_agent import make_fixed_answer_agent
from deerflow.persistence.fixed_questions import MemoryFixedQuestionStore
from deerflow.persistence.fixed_questions.model import FixedQuestionRow
from deerflow.persistence.fixed_questions.sql import FixedQuestionRepository
def _body(**overrides):
data = {
"assistant_id": "lead_agent",
"command": None,
"context": {},
}
data.update(overrides)
return SimpleNamespace(**data)
def _request(store: MemoryFixedQuestionStore):
return SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(fixed_question_store=store),
)
)
@pytest.mark.asyncio
async def test_memory_store_matches_only_enabled_exact_question() -> None:
store = MemoryFixedQuestionStore()
await store.replace_all(
[
{
"id": "enabled",
"question": "系统开放时间?",
"answer": "工作日 09:00—18:00。",
"enabled": True,
"tokens_per_second": 80,
"sort_order": 0,
},
{
"id": "disabled",
"question": "停用问题",
"answer": "不应返回",
"enabled": False,
"sort_order": 1,
},
]
)
match = await store.find_enabled("系统开放时间?")
assert match is not None
assert match["answer"] == "工作日 09:00—18:00。"
assert match["tokens_per_second"] == 80
assert await store.find_enabled("系统开放时间?") is None
assert await store.find_enabled(" 系统开放时间?") is None
assert await store.find_enabled("停用问题") is None
@pytest.mark.asyncio
async def test_sql_store_round_trips_and_exact_matches() -> None:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
try:
async with engine.begin() as connection:
await connection.run_sync(FixedQuestionRow.__table__.create)
repository = FixedQuestionRepository(async_sessionmaker(engine, expire_on_commit=False))
saved = await repository.replace_all(
[
{
"id": "sql-q1",
"question": "SQL 精确问题",
"answer": "SQL 固定回答",
"enabled": True,
"tokens_per_second": 120,
"sort_order": 0,
}
],
updated_by="admin",
)
assert saved[0]["updated_by"] == "admin"
assert saved[0]["tokens_per_second"] == 120
assert (await repository.find_enabled("SQL 精确问题"))["answer"] == "SQL 固定回答"
assert await repository.find_enabled("sql 精确问题") is None
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_lookup_skips_attachments_and_internal_runs() -> None:
store = MemoryFixedQuestionStore()
await store.replace_all(
[
{
"id": "q1",
"question": "固定问题",
"answer": "固定回答",
"enabled": True,
"sort_order": 0,
}
]
)
request = _request(store)
plain_input = normalize_input({"messages": [{"content": "固定问题"}]})
match = await _find_fixed_answer(
body=_body(),
request=request,
graph_input=plain_input,
run_scope=RUN_SCOPE_EXTERNAL_ROOT,
)
assert match is not None
assert match["id"] == "q1"
attachment_input = normalize_input(
{
"messages": [
{
"content": "固定问题",
"additional_kwargs": {"files": [{"name": "context.pdf"}]},
}
]
}
)
assert (
await _find_fixed_answer(
body=_body(),
request=request,
graph_input=attachment_input,
run_scope=RUN_SCOPE_EXTERNAL_ROOT,
)
is None
)
contextual_input = normalize_input(
{
"messages": [
{
"content": "固定问题",
"additional_kwargs": {"prompt_prefix": "网页正文上下文"},
}
]
}
)
assert (
await _find_fixed_answer(
body=_body(),
request=request,
graph_input=contextual_input,
run_scope=RUN_SCOPE_EXTERNAL_ROOT,
)
is None
)
assert (
await _find_fixed_answer(
body=_body(),
request=request,
graph_input=plain_input,
run_scope=RUN_SCOPE_ORCHESTRATION_CHILD,
)
is None
)
@pytest.mark.asyncio
async def test_fixed_answer_agent_streams_chunks_and_persists_full_answer() -> None:
question = "固定问题"
answer = "这是一个足够长的固定回答,用于验证返回内容会拆成多个流式消息块,同时完整写入会话状态。"
agent = make_fixed_answer_agent(
question=question,
answer=answer,
fixed_question_id="q1",
tokens_per_second=1000,
)
chunks: list[str] = []
final_state = None
async for mode, data in agent.astream(
{"messages": [HumanMessage(content=question)]},
stream_mode=["values", "messages"],
):
if mode == "messages":
message, _metadata = data
if isinstance(message.content, str):
chunks.append(message.content)
elif mode == "values":
final_state = data
assert "".join(chunks) == answer
assert len([chunk for chunk in chunks if chunk]) >= 2
assert final_state is not None
assert final_state["messages"][-1].content == answer
assert final_state["messages"][-1].additional_kwargs["fixed_answer"] is True
assert final_state["messages"][-1].additional_kwargs["tokens_per_second"] == 1000
assert final_state["title"] == question
assert final_state["title_provisional"] is False
@pytest.mark.asyncio
async def test_fixed_answer_default_speed_schedules_100_tokens_per_second(
monkeypatch: pytest.MonkeyPatch,
) -> None:
answer = "one two three four five six seven eight nine ten eleven twelve thirteen"
sleeps: list[float] = []
async def record_sleep(delay: float) -> None:
sleeps.append(delay)
monkeypatch.setattr(fixed_answer_module.asyncio, "sleep", record_sleep)
model = fixed_answer_module.FixedAnswerChatModel(
answer=answer,
message_id="speed-test",
fixed_question_id="q-speed",
)
chunks: list[str] = []
async for generation in model._astream([]):
chunks.append(str(generation.message.content))
token_count = len(fixed_answer_module._TOKEN_ENCODING.encode(answer))
assert "".join(chunks) == answer
assert model.tokens_per_second == 100
assert sum(sleeps) == pytest.approx((token_count - 1) / 100)
def test_replace_request_rejects_duplicate_exact_questions() -> None:
row = {
"question": "重复问题",
"answer": "回答",
"enabled": True,
"tokens_per_second": 100,
"sort_order": 0,
}
with pytest.raises(ValidationError):
FixedQuestionReplaceRequest(
questions=[
FixedQuestion(id="q1", **row),
FixedQuestion(id="q2", **row),
]
)
with pytest.raises(ValidationError):
FixedQuestionReplaceRequest(
questions=[
FixedQuestion(id="same-id", **row),
FixedQuestion(
id="same-id",
question="另一个问题",
answer="另一个回答",
enabled=True,
sort_order=1,
),
]
)
def test_fixed_question_stream_speed_defaults_and_bounds() -> None:
default_row = FixedQuestion(
id="default-speed",
question="默认速度",
answer="回答",
)
assert default_row.tokens_per_second == 100
for invalid_speed in (0, 1001):
with pytest.raises(ValidationError):
FixedQuestion(
id=f"invalid-{invalid_speed}",
question="非法速度",
answer="回答",
tokens_per_second=invalid_speed,
)
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more