Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl
# Conflicts: # frontend-web/src/components/workspace/input-box.tsx # frontend-web/src/components/workspace/skill-display-sources.tsx
This commit is contained in:
commit
389df2ace5
108 files changed
+12491
-3151
No files matched your search
+23
-12
@@ -87,24 +87,35 @@ offline-backend-20260512/backend/skill_evolution.json
|
|||||||
skills/
|
skills/
|
||||||
hermes-agent-main/
|
hermes-agent-main/
|
||||||
.gitignore
|
.gitignore
|
||||||
offline-backend-20260512/backend/tests/*
|
offline-backend-20260512/backend/tests/*
|
||||||
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
||||||
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
||||||
# Keep the model configuration regression coverage versioned even though
|
# Keep the model configuration regression coverage versioned even though
|
||||||
# this deployment repository otherwise ignores newly added backend tests.
|
# this deployment repository otherwise ignores newly added backend tests.
|
||||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||||
# Deep-research reliability regression coverage.
|
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
||||||
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
|
!offline-backend-20260512/backend/tests/test_fixed_questions.py
|
||||||
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.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_executor_reliability.py
|
||||||
!offline-backend-20260512/backend/tests/test_deep_research_start_job.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_summarization_human_message.py
|
||||||
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
|
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
|
||||||
# Independent enterprise-research persistence, HTTP and race regressions.
|
# Independent enterprise-research persistence, HTTP and race regressions.
|
||||||
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
|
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
|
||||||
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
|
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
|
||||||
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
|
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
|
||||||
deploy
|
deploy
|
||||||
.history
|
.history
|
||||||
|
|
||||||
# Cloned third-party repos (local only)
|
# Cloned third-party repos (local only)
|
||||||
|
|||||||
+22
-11
@@ -38,6 +38,7 @@ import EmbedChatPage from "./pages/EmbedChatPage";
|
|||||||
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
||||||
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
|
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
|
||||||
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
||||||
|
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
|
||||||
import {
|
import {
|
||||||
isPositionRoundtableStandaloneMode,
|
isPositionRoundtableStandaloneMode,
|
||||||
POSITION_ROUNDTABLE_ROUTE,
|
POSITION_ROUNDTABLE_ROUTE,
|
||||||
@@ -45,7 +46,12 @@ import {
|
|||||||
import { ChatRuntime } from "./pages/WorkspaceRoutes";
|
import { ChatRuntime } from "./pages/WorkspaceRoutes";
|
||||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
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 }) {
|
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||||
const locale = useMemo(() => detectLocale(), []);
|
const locale = useMemo(() => detectLocale(), []);
|
||||||
@@ -102,16 +108,17 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
|||||||
forcedTheme={forcedTheme}
|
forcedTheme={forcedTheme}
|
||||||
>
|
>
|
||||||
<QueryClientProvider>
|
<QueryClientProvider>
|
||||||
<I18nProvider initialLocale={locale}>
|
<AuthSyncWatcher disabled={positionStandaloneMode}>
|
||||||
<ThemeProvider>
|
<I18nProvider initialLocale={locale}>
|
||||||
<ChatProvider>
|
<ThemeProvider>
|
||||||
{!positionStandaloneMode && <AuthSyncWatcher />}
|
<ChatProvider>
|
||||||
{!positionStandaloneMode && <DesensitizeWordsLoader />}
|
{!positionStandaloneMode && <DesensitizeWordsLoader />}
|
||||||
<FontScaleApplier />
|
<FontScaleApplier />
|
||||||
{children}
|
{children}
|
||||||
</ChatProvider>
|
</ChatProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
</AuthSyncWatcher>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</NextThemesProvider>
|
</NextThemesProvider>
|
||||||
);
|
);
|
||||||
@@ -188,6 +195,10 @@ export function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
|
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
|
||||||
|
<Route
|
||||||
|
path="/embed/knowledge/:noteId"
|
||||||
|
element={<WeKnoraWikiPageView publicEmbed />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/roundtable-dashboard"
|
path="/roundtable-dashboard"
|
||||||
element={<DashboardStandalonePage />}
|
element={<DashboardStandalonePage />}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
Gauge,
|
Gauge,
|
||||||
ListTree,
|
ListTree,
|
||||||
|
MessageSquareText,
|
||||||
MousePointerClick,
|
MousePointerClick,
|
||||||
BriefcaseBusiness,
|
BriefcaseBusiness,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -237,6 +238,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
|||||||
按钮管理
|
按钮管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigate("/page/strategy/admin/fixed-questions")}
|
||||||
|
>
|
||||||
|
<MessageSquareText className="size-4" />
|
||||||
|
问题配置
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/strategy/admin/position-roles")}
|
onClick={() => navigate("/page/strategy/admin/position-roles")}
|
||||||
@@ -558,6 +567,12 @@ function LegacyAccordionSidebar({
|
|||||||
按钮管理
|
按钮管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/fixed-questions")}>
|
||||||
|
<MessageSquareText className="size-4" />
|
||||||
|
问题配置
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
|
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
|
||||||
<BriefcaseBusiness className="size-4" />
|
<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-detail";
|
||||||
export * from "./artifact-file-list";
|
export * from "./artifact-directory";
|
||||||
|
export * from "./artifact-file-list";
|
||||||
export * from "./artifact-trigger";
|
export * from "./artifact-trigger";
|
||||||
export * from "./context";
|
export * from "./context";
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { FilesIcon, XIcon } from "lucide-react";
|
import { FilesIcon, XIcon } from "lucide-react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -13,6 +14,10 @@ import {
|
|||||||
} from "@/components/ui/resizable";
|
} from "@/components/ui/resizable";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { apiFetch } from "@/core/api";
|
import { apiFetch } from "@/core/api";
|
||||||
|
import {
|
||||||
|
listThreadArtifacts,
|
||||||
|
mergeArtifactPaths,
|
||||||
|
} from "@/core/artifacts/listing";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||||
@@ -32,6 +37,7 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ArtifactFileDetail,
|
ArtifactFileDetail,
|
||||||
|
ArtifactDirectory,
|
||||||
ArtifactFileList,
|
ArtifactFileList,
|
||||||
useArtifacts,
|
useArtifacts,
|
||||||
} from "../artifacts";
|
} from "../artifacts";
|
||||||
@@ -40,6 +46,7 @@ import { SkillDisplaySources } from "../skill-display-sources";
|
|||||||
|
|
||||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||||
|
const DIRECTORY_OPEN_MODE = { chat: 48, artifacts: 52 };
|
||||||
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
||||||
|
|
||||||
type BackendReferenceSource = Parameters<typeof SkillDisplaySources>[0]["sources"][number];
|
type BackendReferenceSource = Parameters<typeof SkillDisplaySources>[0]["sources"][number];
|
||||||
@@ -78,7 +85,7 @@ const ChatBox: React.FC<{
|
|||||||
threadId,
|
threadId,
|
||||||
disableArtifacts = false,
|
disableArtifacts = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { thread } = useThread();
|
const { isMock, thread } = useThread();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const threadIdRef = useRef(threadId);
|
const threadIdRef = useRef(threadId);
|
||||||
const artifactsCountRef = useRef(0);
|
const artifactsCountRef = useRef(0);
|
||||||
@@ -92,6 +99,8 @@ const ChatBox: React.FC<{
|
|||||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
|
const artifactDirectoryModeEnabled =
|
||||||
|
settings.display.artifactDirectoryModeEnabled ?? false;
|
||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||||
// Frontend RAG modes write their own hidden source tool messages. They use
|
// Frontend RAG modes write their own hidden source tool messages. They use
|
||||||
@@ -110,6 +119,52 @@ const ChatBox: React.FC<{
|
|||||||
referencesEnabled && !hasFrontendRag;
|
referencesEnabled && !hasFrontendRag;
|
||||||
const { skills } = useSkills();
|
const { skills } = useSkills();
|
||||||
|
|
||||||
|
const canScanArtifactDirectory =
|
||||||
|
artifactDirectoryModeEnabled &&
|
||||||
|
!disableArtifacts &&
|
||||||
|
!isMock &&
|
||||||
|
!!threadId &&
|
||||||
|
threadId !== "new";
|
||||||
|
const artifactListingQuery = useQuery({
|
||||||
|
queryKey: ["artifact-directory", threadId],
|
||||||
|
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
|
||||||
|
enabled: canScanArtifactDirectory,
|
||||||
|
refetchInterval: thread.isLoading ? 1_500 : false,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
const checkpointArtifacts = useMemo(
|
||||||
|
() => (disableArtifacts ? [] : (thread.values.artifacts ?? [])),
|
||||||
|
[disableArtifacts, thread.values.artifacts],
|
||||||
|
);
|
||||||
|
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
|
||||||
|
const availableArtifacts = useMemo(
|
||||||
|
() =>
|
||||||
|
artifactDirectoryModeEnabled
|
||||||
|
? mergeArtifactPaths(
|
||||||
|
checkpointArtifacts,
|
||||||
|
(artifactListingQuery.data?.files ?? []).map((file) => file.path),
|
||||||
|
)
|
||||||
|
: checkpointArtifacts,
|
||||||
|
[
|
||||||
|
artifactDirectoryModeEnabled,
|
||||||
|
artifactListingQuery.data?.files,
|
||||||
|
checkpointArtifacts,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canScanArtifactDirectory || thread.isLoading) return;
|
||||||
|
// Run once after streaming settles as well as when checkpoint artifacts
|
||||||
|
// change, so files written without present_files are not missed by the
|
||||||
|
// last polling tick.
|
||||||
|
void artifactListingQuery.refetch();
|
||||||
|
}, [
|
||||||
|
artifactListingQuery.refetch,
|
||||||
|
canScanArtifactDirectory,
|
||||||
|
checkpointArtifactSignature,
|
||||||
|
thread.isLoading,
|
||||||
|
]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
artifacts,
|
artifacts,
|
||||||
open: artifactsOpen,
|
open: artifactsOpen,
|
||||||
@@ -302,7 +357,8 @@ const ChatBox: React.FC<{
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (threadIdRef.current !== threadId) {
|
const threadChanged = threadIdRef.current !== threadId;
|
||||||
|
if (threadChanged) {
|
||||||
threadIdRef.current = threadId;
|
threadIdRef.current = threadId;
|
||||||
artifactsCountRef.current = 0;
|
artifactsCountRef.current = 0;
|
||||||
deselect();
|
deselect();
|
||||||
@@ -316,15 +372,21 @@ const ChatBox: React.FC<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update artifacts from the current thread
|
// Update artifacts from the current thread
|
||||||
const nextArtifacts = disableArtifacts ? [] : (thread.values.artifacts ?? []);
|
const nextArtifacts = availableArtifacts;
|
||||||
const hadArtifacts = artifactsCountRef.current > 0;
|
const hadArtifacts = artifactsCountRef.current > 0;
|
||||||
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
|
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
|
||||||
artifactsCountRef.current = nextArtifacts.length;
|
artifactsCountRef.current = nextArtifacts.length;
|
||||||
setArtifacts(nextArtifacts);
|
const artifactsChanged =
|
||||||
|
artifacts.length !== nextArtifacts.length ||
|
||||||
|
artifacts.some((artifact, index) => artifact !== nextArtifacts[index]);
|
||||||
|
if (artifactsChanged) {
|
||||||
|
setArtifacts(nextArtifacts);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!disableArtifacts &&
|
!disableArtifacts &&
|
||||||
(hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) &&
|
nextArtifacts.length > 0 &&
|
||||||
!selectedArtifact
|
((artifactDirectoryModeEnabled && threadChanged) ||
|
||||||
|
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
|
||||||
) {
|
) {
|
||||||
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
||||||
setArtifactsOpen(true);
|
setArtifactsOpen(true);
|
||||||
@@ -351,15 +413,41 @@ const ChatBox: React.FC<{
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
threadId,
|
threadId,
|
||||||
|
artifactDirectoryModeEnabled,
|
||||||
autoSelectFirstArtifact,
|
autoSelectFirstArtifact,
|
||||||
deselect,
|
deselect,
|
||||||
selectArtifact,
|
selectArtifact,
|
||||||
selectedArtifact,
|
selectedArtifact,
|
||||||
|
artifacts,
|
||||||
setArtifacts,
|
setArtifacts,
|
||||||
thread.values.artifacts,
|
availableArtifacts,
|
||||||
disableArtifacts,
|
disableArtifacts,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
|
||||||
|
useEffect(() => {
|
||||||
|
const wasEnabled = directoryModeWasEnabledRef.current;
|
||||||
|
directoryModeWasEnabledRef.current = artifactDirectoryModeEnabled;
|
||||||
|
if (
|
||||||
|
wasEnabled ||
|
||||||
|
!artifactDirectoryModeEnabled ||
|
||||||
|
availableArtifacts.length === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedArtifact) {
|
||||||
|
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!);
|
||||||
|
}
|
||||||
|
setArtifactsOpen(true);
|
||||||
|
setSideTab("document");
|
||||||
|
}, [
|
||||||
|
artifactDirectoryModeEnabled,
|
||||||
|
availableArtifacts,
|
||||||
|
selectArtifact,
|
||||||
|
selectedArtifact,
|
||||||
|
setArtifactsOpen,
|
||||||
|
]);
|
||||||
|
|
||||||
const artifactPanelOpen = useMemo(() => {
|
const artifactPanelOpen = useMemo(() => {
|
||||||
if (disableArtifacts) {
|
if (disableArtifacts) {
|
||||||
return false;
|
return false;
|
||||||
@@ -370,7 +458,10 @@ const ChatBox: React.FC<{
|
|||||||
return artifactsOpen;
|
return artifactsOpen;
|
||||||
}, [artifactsOpen, artifacts, disableArtifacts]);
|
}, [artifactsOpen, artifacts, disableArtifacts]);
|
||||||
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
|
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
|
||||||
const showTabs = !!selectedArtifact && hasCitationReferences;
|
const showTabs =
|
||||||
|
hasCitationReferences &&
|
||||||
|
(!!selectedArtifact ||
|
||||||
|
(artifactDirectoryModeEnabled && artifacts.length > 0));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedArtifact) {
|
if (selectedArtifact) {
|
||||||
@@ -390,12 +481,103 @@ const ChatBox: React.FC<{
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (layoutRef.current) {
|
if (layoutRef.current) {
|
||||||
if (sidePanelOpen) {
|
if (sidePanelOpen) {
|
||||||
layoutRef.current.setLayout(OPEN_MODE);
|
layoutRef.current.setLayout(
|
||||||
|
artifactDirectoryModeEnabled && artifactPanelOpen
|
||||||
|
? DIRECTORY_OPEN_MODE
|
||||||
|
: OPEN_MODE,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
layoutRef.current.setLayout(CLOSE_MODE);
|
layoutRef.current.setLayout(CLOSE_MODE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [sidePanelOpen]);
|
}, [artifactDirectoryModeEnabled, artifactPanelOpen, sidePanelOpen]);
|
||||||
|
|
||||||
|
const artifactDocumentPanel = artifactDirectoryModeEnabled ? (
|
||||||
|
<div className="flex size-full min-w-0 overflow-hidden rounded-lg border">
|
||||||
|
<ArtifactDirectory
|
||||||
|
artifacts={artifacts}
|
||||||
|
selectedArtifact={selectedArtifact}
|
||||||
|
onSelect={(artifact) => {
|
||||||
|
selectArtifact(artifact);
|
||||||
|
setArtifactsOpen(true);
|
||||||
|
}}
|
||||||
|
onRefresh={
|
||||||
|
canScanArtifactDirectory
|
||||||
|
? () => void artifactListingQuery.refetch()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
refreshing={artifactListingQuery.isFetching}
|
||||||
|
/>
|
||||||
|
<div className="relative min-w-0 flex-1 overflow-hidden">
|
||||||
|
{selectedArtifact ? (
|
||||||
|
<ArtifactFileDetail
|
||||||
|
key={selectedArtifactKey ?? selectedArtifact}
|
||||||
|
className="size-full"
|
||||||
|
filepath={selectedArtifact}
|
||||||
|
threadId={threadId}
|
||||||
|
allowVirtualMarkdownEdit
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="absolute top-1 right-1 z-30">
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setArtifactsOpen(false)}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ConversationEmptyState
|
||||||
|
icon={<FilesIcon />}
|
||||||
|
title="请选择文件"
|
||||||
|
description="从左侧目录中选择一个文件进行查看"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : selectedArtifact ? (
|
||||||
|
<ArtifactFileDetail
|
||||||
|
key={selectedArtifactKey ?? selectedArtifact}
|
||||||
|
className="size-full"
|
||||||
|
filepath={selectedArtifact}
|
||||||
|
threadId={threadId}
|
||||||
|
allowVirtualMarkdownEdit
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="relative flex size-full justify-center">
|
||||||
|
<div className="absolute top-1 right-1 z-30">
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setArtifactsOpen(false)}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{artifacts.length === 0 ? (
|
||||||
|
<ConversationEmptyState
|
||||||
|
icon={<FilesIcon />}
|
||||||
|
title="No artifact selected"
|
||||||
|
description="Select an artifact to view its details"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||||
|
<header className="shrink-0">
|
||||||
|
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||||
|
</header>
|
||||||
|
<main className="min-h-0 grow">
|
||||||
|
<ArtifactFileList
|
||||||
|
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||||
|
files={artifacts}
|
||||||
|
threadId={threadId}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (disableArtifacts) {
|
if (disableArtifacts) {
|
||||||
return (
|
return (
|
||||||
@@ -455,13 +637,7 @@ const ChatBox: React.FC<{
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||||
<ArtifactFileDetail
|
{artifactDocumentPanel}
|
||||||
key={selectedArtifactKey ?? selectedArtifact}
|
|
||||||
className="size-full"
|
|
||||||
filepath={selectedArtifact}
|
|
||||||
threadId={threadId}
|
|
||||||
allowVirtualMarkdownEdit
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
||||||
<ReferencePanel
|
<ReferencePanel
|
||||||
@@ -470,14 +646,8 @@ const ChatBox: React.FC<{
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : selectedArtifact ? (
|
) : artifactPanelOpen ? (
|
||||||
<ArtifactFileDetail
|
artifactDocumentPanel
|
||||||
key={selectedArtifactKey ?? selectedArtifact}
|
|
||||||
className="size-full"
|
|
||||||
filepath={selectedArtifact}
|
|
||||||
threadId={threadId}
|
|
||||||
allowVirtualMarkdownEdit
|
|
||||||
/>
|
|
||||||
) : hasCitationReferences ? (
|
) : hasCitationReferences ? (
|
||||||
<div className="relative size-full overflow-hidden">
|
<div className="relative size-full overflow-hidden">
|
||||||
<div className="absolute top-1 right-1 z-30">
|
<div className="absolute top-1 right-1 z-30">
|
||||||
@@ -497,41 +667,7 @@ const ChatBox: React.FC<{
|
|||||||
sources={referenceSources}
|
sources={referenceSources}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
<div className="relative flex size-full justify-center">
|
|
||||||
<div className="absolute top-1 right-1 z-30">
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setArtifactsOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XIcon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{thread.values.artifacts?.length === 0 ? (
|
|
||||||
<ConversationEmptyState
|
|
||||||
icon={<FilesIcon />}
|
|
||||||
title="No artifact selected"
|
|
||||||
description="Select an artifact to view its details"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
|
||||||
<header className="shrink-0">
|
|
||||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
|
||||||
</header>
|
|
||||||
<main className="min-h-0 grow">
|
|
||||||
<ArtifactFileList
|
|
||||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
|
||||||
files={thread.values.artifacts ?? []}
|
|
||||||
threadId={threadId}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import type { ChatStatus } from "ai";
|
import type { ChatStatus } from "ai";
|
||||||
import {
|
import {
|
||||||
BarChart3Icon,
|
BarChart3Icon,
|
||||||
BrainCircuitIcon,
|
BrainCircuitIcon,
|
||||||
BookMarkedIcon,
|
BookMarkedIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ClipboardCheckIcon,
|
ClipboardCheckIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
|
FolderTreeIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
LightbulbIcon,
|
LightbulbIcon,
|
||||||
MessageCircleQuestionIcon,
|
MessageCircleQuestionIcon,
|
||||||
@@ -446,12 +447,20 @@ export function InputBox({
|
|||||||
[onContextChange, context],
|
[onContextChange, context],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleWritingModeToggle = useCallback(() => {
|
const handleWritingModeToggle = useCallback(() => {
|
||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
writing_mode: !context.writing_mode,
|
writing_mode: !context.writing_mode,
|
||||||
});
|
});
|
||||||
}, [context, onContextChange]);
|
}, [context, onContextChange]);
|
||||||
|
|
||||||
|
const artifactDirectoryModeEnabled =
|
||||||
|
localSettings.display.artifactDirectoryModeEnabled ?? false;
|
||||||
|
const handleArtifactDirectoryModeToggle = useCallback(() => {
|
||||||
|
setLocalSettings("display", {
|
||||||
|
artifactDirectoryModeEnabled: !artifactDirectoryModeEnabled,
|
||||||
|
});
|
||||||
|
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
||||||
|
|
||||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||||
const handleMemoryInjectionToggle = useCallback(() => {
|
const handleMemoryInjectionToggle = useCallback(() => {
|
||||||
@@ -854,7 +863,7 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!frontendRagActive && <Tooltip
|
{!frontendRagActive && <Tooltip
|
||||||
content={
|
content={
|
||||||
context.writing_mode
|
context.writing_mode
|
||||||
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
|
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
|
||||||
@@ -878,9 +887,38 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
<FileTextIcon className="size-3" />
|
<FileTextIcon className="size-3" />
|
||||||
<span className="text-xs font-normal">写作</span>
|
<span className="text-xs font-normal">写作</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>}
|
</Tooltip>}
|
||||||
{!frontendRagActive && <Tooltip
|
{!frontendRagActive && <Tooltip
|
||||||
|
content={
|
||||||
|
artifactDirectoryModeEnabled
|
||||||
|
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
|
||||||
|
: "开启文件模式:在右侧目录中浏览当前对话生成的全部文件"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PromptInputButton
|
||||||
|
type="button"
|
||||||
|
aria-pressed={artifactDirectoryModeEnabled}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"gap-1.5! rounded-full px-3! shadow-none",
|
||||||
|
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||||
|
artifactDirectoryModeEnabled
|
||||||
|
? "border border-sky-300 bg-sky-50 text-sky-700 hover:bg-sky-100 dark:border-sky-600 dark:bg-sky-950/45 dark:text-sky-200"
|
||||||
|
: compact
|
||||||
|
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||||
|
)}
|
||||||
|
onClick={handleArtifactDirectoryModeToggle}
|
||||||
|
>
|
||||||
|
{artifactDirectoryModeEnabled && (
|
||||||
|
<CheckIcon className="size-3 text-sky-700 dark:text-sky-200" />
|
||||||
|
)}
|
||||||
|
<FolderTreeIcon className="size-3" />
|
||||||
|
<span className="text-xs font-normal">文件</span>
|
||||||
|
</PromptInputButton>
|
||||||
|
</Tooltip>}
|
||||||
|
{!frontendRagActive && <Tooltip
|
||||||
content={
|
content={
|
||||||
memoryInjectionEnabled
|
memoryInjectionEnabled
|
||||||
? "记忆注入已开启:对话前会注入历史记忆"
|
? "记忆注入已开启:对话前会注入历史记忆"
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ function SelectionContent({
|
|||||||
error,
|
error,
|
||||||
source,
|
source,
|
||||||
variant,
|
variant,
|
||||||
|
readOnly,
|
||||||
}: {
|
}: {
|
||||||
rows: LlmWikiKnowledgeBase[];
|
rows: LlmWikiKnowledgeBase[];
|
||||||
value: string[];
|
value: string[];
|
||||||
@@ -86,6 +87,7 @@ function SelectionContent({
|
|||||||
error: unknown;
|
error: unknown;
|
||||||
source: SelectorSource;
|
source: SelectorSource;
|
||||||
variant: SelectorVariant;
|
variant: SelectorVariant;
|
||||||
|
readOnly: boolean;
|
||||||
}) {
|
}) {
|
||||||
const selected = useMemo(() => new Set(value), [value]);
|
const selected = useMemo(() => new Set(value), [value]);
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
@@ -97,6 +99,7 @@ function SelectionContent({
|
|||||||
}, [rows, search]);
|
}, [rows, search]);
|
||||||
const groups = useMemo(() => groupRows(filtered), [filtered]);
|
const groups = useMemo(() => groupRows(filtered), [filtered]);
|
||||||
const toggle = (id: string) => {
|
const toggle = (id: string) => {
|
||||||
|
if (readOnly) return;
|
||||||
onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]);
|
onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -168,6 +171,7 @@ function SelectionContent({
|
|||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
checked={selected.has(item.id)}
|
checked={selected.has(item.id)}
|
||||||
|
disabled={readOnly}
|
||||||
onCheckedChange={() => toggle(item.id)}
|
onCheckedChange={() => toggle(item.id)}
|
||||||
onSelect={(event) => event.preventDefault()}
|
onSelect={(event) => event.preventDefault()}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -183,6 +187,7 @@ function SelectionContent({
|
|||||||
type="button"
|
type="button"
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
aria-checked={selected.has(item.id)}
|
aria-checked={selected.has(item.id)}
|
||||||
|
disabled={readOnly}
|
||||||
onClick={() => toggle(item.id)}
|
onClick={() => toggle(item.id)}
|
||||||
className={cn(
|
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",
|
"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>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
|
<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>
|
<span className="text-[11px] text-muted-foreground">
|
||||||
<div className="flex items-center gap-1">
|
{readOnly ? `本对话已选 ${value.length} 个` : `已选择 ${value.length} 个`}
|
||||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}>
|
</span>
|
||||||
全选
|
{readOnly ? (
|
||||||
</Button>
|
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
|
||||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
|
) : (
|
||||||
清空
|
<div className="flex items-center gap-1">
|
||||||
</Button>
|
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}>
|
||||||
</div>
|
全选
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -226,6 +237,7 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
agentId,
|
agentId,
|
||||||
autoSelectAll = false,
|
autoSelectAll = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
readOnly = false,
|
||||||
className,
|
className,
|
||||||
label = "知识空间",
|
label = "知识空间",
|
||||||
variant = "popover",
|
variant = "popover",
|
||||||
@@ -236,6 +248,8 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
agentId?: string | null;
|
agentId?: string | null;
|
||||||
autoSelectAll?: boolean;
|
autoSelectAll?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Keep the selected knowledge spaces visible without allowing the thread scope to change. */
|
||||||
|
readOnly?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
variant?: SelectorVariant;
|
variant?: SelectorVariant;
|
||||||
@@ -293,6 +307,7 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
error={error}
|
error={error}
|
||||||
source={source}
|
source={source}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -315,9 +330,14 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedName = value.length === 1
|
const selectedNames = value
|
||||||
? rows.find((item) => item.id === value[0])?.name
|
.map((id) => rows.find((item) => item.id === id)?.name)
|
||||||
: null;
|
.filter((name): name is string => !!name);
|
||||||
|
const selectedLabel = selectedNames.length > 0
|
||||||
|
? selectedNames.join("、")
|
||||||
|
: value.length > 0
|
||||||
|
? `已选 ${value.length} 个知识空间`
|
||||||
|
: label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
@@ -332,9 +352,10 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
|
title={value.length > 0 ? selectedLabel : undefined}
|
||||||
>
|
>
|
||||||
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <Database className="size-3.5" />}
|
{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 && (
|
{value.length > 0 && (
|
||||||
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
|
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
|
||||||
{value.length}
|
{value.length}
|
||||||
|
|||||||
@@ -341,9 +341,9 @@ function ToolCall({
|
|||||||
ragModeEnabled?: boolean;
|
ragModeEnabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const displayMatch =
|
const displayMatch =
|
||||||
result !== undefined && isDisplaySamplePayload(result)
|
result !== undefined && isDisplaySamplePayload(result)
|
||||||
? null
|
? null
|
||||||
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
||||||
null);
|
null);
|
||||||
const displayItems =
|
const displayItems =
|
||||||
@@ -352,18 +352,24 @@ function ToolCall({
|
|||||||
...item,
|
...item,
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
skillName: displayMatch.skillName,
|
skillName: displayMatch.skillName,
|
||||||
mode: item.displayMode ?? displayMatch.display.mode,
|
mode: item.displayMode ?? displayMatch.display.mode,
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
if (displayItems.length > 0) {
|
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
|
||||||
return (
|
const wikiSearchLabel = name === "llmwiki_search"
|
||||||
<ChainOfThoughtStep
|
? wikiQuery
|
||||||
key={id}
|
? `Wiki 文档检索 "${wikiQuery}"`
|
||||||
label={
|
: "Wiki 文档检索"
|
||||||
displayMatch?.display.mode === "citation" || !displayMatch
|
: null;
|
||||||
? "检索参考来源"
|
if (displayItems.length > 0) {
|
||||||
: `获取 ${displayItems.length} 条结果`
|
return (
|
||||||
}
|
<ChainOfThoughtStep
|
||||||
|
key={id}
|
||||||
|
label={
|
||||||
|
wikiSearchLabel || (displayMatch?.display.mode === "citation" || !displayMatch
|
||||||
|
? "检索参考来源"
|
||||||
|
: `获取 ${displayItems.length} 条结果`)
|
||||||
|
}
|
||||||
icon={SearchIcon}
|
icon={SearchIcon}
|
||||||
>
|
>
|
||||||
<DisplayResultsList results={displayItems} />
|
<DisplayResultsList results={displayItems} />
|
||||||
@@ -387,7 +393,7 @@ function ToolCall({
|
|||||||
mode: "citation" as const,
|
mode: "citation" as const,
|
||||||
}));
|
}));
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep key={id} label="检索参考来源" icon={SearchIcon}>
|
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
||||||
<DisplayResultsList results={defaultItems} />
|
<DisplayResultsList results={defaultItems} />
|
||||||
</ChainOfThoughtStep>
|
</ChainOfThoughtStep>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ExternalLinkIcon, FileTextIcon } from "lucide-react";
|
import { BookOpenTextIcon, ChevronRightIcon, ExternalLinkIcon, FileTextIcon } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -15,9 +15,11 @@ import {
|
|||||||
} from "@/core/llmwiki/source-links";
|
} from "@/core/llmwiki/source-links";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
|
import { reasoningPlugins } from "@/core/streamdown";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||||
|
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||||
|
|
||||||
/** Whether a source URL is an internal WeKnora preview pointer. */
|
/** Whether a source URL is an internal WeKnora preview pointer. */
|
||||||
export function isWeKnoraSourcePreviewUrl(url?: string | null): boolean {
|
export function isWeKnoraSourcePreviewUrl(url?: string | null): boolean {
|
||||||
@@ -71,8 +73,20 @@ export function SourceCard({
|
|||||||
const cleanupWords =
|
const cleanupWords =
|
||||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||||
const snippet = compactReferenceText(content).slice(0, 280);
|
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
|
||||||
const paragraphs = segmentReferenceText(content);
|
const matchedHeading = isWikiSource
|
||||||
|
? String(
|
||||||
|
(source.raw?.matched_heading as string | undefined) ||
|
||||||
|
(source.raw?.matched_sections as Array<{ heading?: string }> | undefined)?.find(
|
||||||
|
(section) => section.heading,
|
||||||
|
)?.heading ||
|
||||||
|
"",
|
||||||
|
).trim()
|
||||||
|
: "";
|
||||||
|
const summary = isWikiSource && typeof source.raw?.summary === "string"
|
||||||
|
? source.raw.summary
|
||||||
|
: "";
|
||||||
|
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
|
||||||
const score =
|
const score =
|
||||||
typeof source.score === "number"
|
typeof source.score === "number"
|
||||||
? source.score <= 1
|
? source.score <= 1
|
||||||
@@ -81,13 +95,22 @@ export function SourceCard({
|
|||||||
: null;
|
: null;
|
||||||
const meta = [
|
const meta = [
|
||||||
source.source,
|
source.source,
|
||||||
|
isWikiSource ? "检索对象:Wiki 文档" : null,
|
||||||
|
matchedHeading ? `命中章节:${matchedHeading}` : null,
|
||||||
source.author ? `作者 ${source.author}` : null,
|
source.author ? `作者 ${source.author}` : null,
|
||||||
source.type && source.type !== "result" ? source.type : null,
|
!isWikiSource && source.type && source.type !== "result" ? source.type : null,
|
||||||
source.time,
|
source.time,
|
||||||
score ? `相关度 ${score}` : null,
|
score ? `相关度 ${score}` : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const isCitation = source.mode === "citation";
|
const isCitation = source.mode === "citation";
|
||||||
const showReferenceNumber = isCitation;
|
const showReferenceNumber = isCitation;
|
||||||
|
const openSource = () => {
|
||||||
|
if (previewTarget) {
|
||||||
|
setDetailOpen(true);
|
||||||
|
} else {
|
||||||
|
setRelatedOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -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)]",
|
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
|
||||||
"cursor-pointer",
|
"cursor-pointer",
|
||||||
)}
|
)}
|
||||||
onClick={() => setRelatedOpen(true)}
|
onClick={openSource}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setRelatedOpen(true);
|
openSource();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -136,11 +159,16 @@ export function SourceCard({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{source.url && (
|
{(source.url || isWikiSource) && (
|
||||||
previewTarget ? (
|
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">
|
<span className={cn(
|
||||||
<FileTextIcon className="size-3.5" />
|
"mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium",
|
||||||
查看原文
|
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>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
||||||
@@ -148,19 +176,9 @@ export function SourceCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{previewTarget && (
|
{previewTarget && (
|
||||||
<div className="mt-3 flex justify-start border-t pt-2">
|
<div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground">
|
||||||
<Button
|
<span>{isWikiSource ? "点击卡片查看完整 Wiki" : "点击卡片查看详情"}</span>
|
||||||
size="sm"
|
<ChevronRightIcon className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||||
variant="ghost"
|
|
||||||
className="text-muted-foreground hover:text-primary h-7 px-1.5 text-xs"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
setDetailOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ExternalLinkIcon className="mr-1.5 size-3.5" />
|
|
||||||
查看详情
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -173,7 +191,7 @@ export function SourceCard({
|
|||||||
onOpenChange={setDetailOpen}
|
onOpenChange={setDetailOpen}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
{!previewTarget && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||||
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
|
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-start justify-between gap-3 pr-8">
|
<div className="flex items-start justify-between gap-3 pr-8">
|
||||||
@@ -210,13 +228,23 @@ export function SourceCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{paragraphs.length > 0 ? (
|
{content ? (
|
||||||
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
|
<div className="max-h-[62vh] overflow-y-auto rounded-xl border bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
|
||||||
{paragraphs.map((paragraph, index) => (
|
{isWikiSource && (
|
||||||
<p key={index} className="whitespace-pre-wrap">
|
<div className="mb-4 flex items-center gap-2 border-b pb-3 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||||
{paragraph}
|
<BookOpenTextIcon className="size-4" />
|
||||||
</p>
|
本次检索命中的 Wiki 文档内容
|
||||||
))}
|
</div>
|
||||||
|
)}
|
||||||
|
<article className="mx-auto max-w-3xl rounded-lg bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
|
||||||
|
<MarkdownContent
|
||||||
|
className="max-w-none"
|
||||||
|
content={content}
|
||||||
|
isLoading={false}
|
||||||
|
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||||
|
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{source.url && !previewTarget && (
|
{source.url && !previewTarget && (
|
||||||
@@ -225,7 +253,7 @@ export function SourceCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -254,44 +282,18 @@ function compactReferenceText(text: string): string {
|
|||||||
return text.replace(/\s+/g, " ").trim();
|
return text.replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function segmentReferenceText(text: string): string[] {
|
function plainTextReferencePreview(text: string): string {
|
||||||
const explicit = text
|
return compactReferenceText(
|
||||||
.split(/\n{2,}/)
|
text
|
||||||
.map((item) => item.trim())
|
.replace(/^---[\s\S]*?---\s*/m, "")
|
||||||
.filter(Boolean);
|
.replace(/```[\s\S]*?```/g, " ")
|
||||||
const base = explicit.length > 1 ? explicit : splitLongParagraph(text);
|
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||||
return base.slice(0, 80);
|
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||||
}
|
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
|
||||||
|
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
||||||
function splitLongParagraph(text: string): string[] {
|
.replace(/^#{1,6}\s+/gm, "")
|
||||||
const normalized = text
|
.replace(/^\s*[-*+]\s+/gm, "")
|
||||||
.split(/\n+/)
|
.replace(/^\s*\d+[.)]\s+/gm, "")
|
||||||
.map((item) => item.trim())
|
.replace(/[>*_`~|]/g, " "),
|
||||||
.filter(Boolean)
|
);
|
||||||
.join("\n");
|
|
||||||
if (normalized.length <= 360) {
|
|
||||||
return normalized ? [normalized] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const sentences = normalized
|
|
||||||
.split(/(?<=[。!?!?;;])\s*/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (sentences.length <= 1) {
|
|
||||||
return normalized.match(/[\s\S]{1,360}/g) ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const paragraphs: string[] = [];
|
|
||||||
let current = "";
|
|
||||||
for (const sentence of sentences) {
|
|
||||||
if ((current + sentence).length > 360 && current) {
|
|
||||||
paragraphs.push(current.trim());
|
|
||||||
current = "";
|
|
||||||
}
|
|
||||||
current += sentence;
|
|
||||||
}
|
|
||||||
if (current.trim()) {
|
|
||||||
paragraphs.push(current.trim());
|
|
||||||
}
|
|
||||||
return paragraphs;
|
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
HistoryIcon,
|
HistoryIcon,
|
||||||
ListTreeIcon,
|
ListTreeIcon,
|
||||||
MessageCircleQuestionIcon,
|
MessageCircleQuestionIcon,
|
||||||
|
MessageSquareTextIcon,
|
||||||
ScrollTextIcon,
|
ScrollTextIcon,
|
||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
@@ -288,6 +289,16 @@ export function WorkspaceNavMenu() {
|
|||||||
推荐问题配置
|
推荐问题配置
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
navigate("/page/strategy/admin/fixed-questions")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MessageSquareTextIcon />
|
||||||
|
问题配置
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && logSystem?.enabled && logSystem.url && (
|
{isAdmin && logSystem?.enabled && logSystem.url && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { apiFetch } from "@/core/api";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
|
export interface ArtifactListingFile {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
mimeType: string | null;
|
||||||
|
modifiedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtifactListing {
|
||||||
|
threadId: string;
|
||||||
|
files: ArtifactListingFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan the thread's outputs directory instead of relying only on artifacts
|
||||||
|
* explicitly surfaced by `present_files`. This is used by file mode so files
|
||||||
|
* written by any tool are still discoverable from the right-hand directory.
|
||||||
|
*/
|
||||||
|
export async function listThreadArtifacts(
|
||||||
|
threadId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ArtifactListing> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts`,
|
||||||
|
{ method: "GET", signal },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text().catch(() => "");
|
||||||
|
throw new Error(
|
||||||
|
`Failed to list artifacts (${response.status})${detail ? `: ${detail}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
thread_id?: string;
|
||||||
|
files?: Array<{
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
size_bytes: number;
|
||||||
|
mime_type: string | null;
|
||||||
|
modified_at: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
threadId: data.thread_id ?? threadId,
|
||||||
|
files: (data.files ?? []).map((file) => ({
|
||||||
|
path: file.path,
|
||||||
|
name: file.name,
|
||||||
|
sizeBytes: file.size_bytes,
|
||||||
|
mimeType: file.mime_type,
|
||||||
|
modifiedAt: file.modified_at,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the generated file path represented by a real or streaming artifact. */
|
||||||
|
export function artifactPathname(artifact: string): string {
|
||||||
|
const trimmed = artifact.trim();
|
||||||
|
if (!trimmed.startsWith("write-file:")) {
|
||||||
|
return trimmed.replace(/^\/+/, "");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(new URL(trimmed).pathname).replace(/^\/+/, "");
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function artifactRelativePath(artifact: string): string {
|
||||||
|
const pathname = artifactPathname(artifact);
|
||||||
|
return pathname.replace(/^mnt\/user-data\/outputs\/?/i, "") || pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the checkpoint order, append files found only on disk from old to new,
|
||||||
|
* and replace a completed streaming URL with its durable path.
|
||||||
|
*/
|
||||||
|
export function mergeArtifactPaths(
|
||||||
|
checkpointArtifacts: string[],
|
||||||
|
discoveredArtifacts: string[],
|
||||||
|
): string[] {
|
||||||
|
const merged: string[] = [];
|
||||||
|
const indexByPath = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const artifact of checkpointArtifacts) {
|
||||||
|
const key = artifactPathname(artifact);
|
||||||
|
if (!key || indexByPath.has(key)) continue;
|
||||||
|
indexByPath.set(key, merged.length);
|
||||||
|
merged.push(artifact);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The listing endpoint returns newest first. Append in chronological order
|
||||||
|
// so the last item remains the newest artifact for the existing auto-open.
|
||||||
|
for (const artifact of [...discoveredArtifacts].reverse()) {
|
||||||
|
const key = artifactPathname(artifact);
|
||||||
|
if (!key) continue;
|
||||||
|
const existingIndex = indexByPath.get(key);
|
||||||
|
if (existingIndex !== undefined) {
|
||||||
|
if (merged[existingIndex]?.startsWith("write-file:")) {
|
||||||
|
merged[existingIndex] = artifact;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
indexByPath.set(key, merged.length);
|
||||||
|
merged.push(artifact);
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
@@ -49,6 +49,17 @@ function normalizeIdentityPart(value: unknown): string {
|
|||||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
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 {
|
function readUserInfoIdentity(): string | null {
|
||||||
let info: Record<string, unknown> | null = null;
|
let info: Record<string, unknown> | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -81,20 +92,24 @@ function readUserInfoIdentity(): string | null {
|
|||||||
/**
|
/**
|
||||||
* A stable cross-tab identity key for "did the account really change?" checks.
|
* 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
|
* Some external login paths can re-issue a backend ``user_id`` or use a
|
||||||
* to the same business account. Prefer business-facing identifiers
|
* different synthetic email domain while resolving to the same business
|
||||||
* (email/username/userInfo) and keep ``user_id`` only as the final fallback.
|
* 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(
|
export function getAccountIdentityKey(
|
||||||
auth: LoginByUsernameResponse | null = getStoredAuth(),
|
auth: LoginByUsernameResponse | null = getStoredAuth(),
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!auth) return null;
|
if (!auth) return null;
|
||||||
|
|
||||||
const email = normalizeIdentityPart(auth.email);
|
const loginIdentity = identityFromLoginName(auth.username);
|
||||||
if (email) return `email:${email}`;
|
if (loginIdentity) return loginIdentity;
|
||||||
|
|
||||||
const username = normalizeIdentityPart(auth.username);
|
const email = normalizeIdentityPart(auth.email);
|
||||||
if (username) return `username:${username}`;
|
if (email) {
|
||||||
|
const localPart = email.split("@", 1)[0];
|
||||||
|
return localPart ? `account:${localPart}` : `email:${email}`;
|
||||||
|
}
|
||||||
|
|
||||||
const userInfoIdentity = readUserInfoIdentity();
|
const userInfoIdentity = readUserInfoIdentity();
|
||||||
if (userInfoIdentity) return `userInfo:${userInfoIdentity}`;
|
if (userInfoIdentity) return `userInfo:${userInfoIdentity}`;
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
"use client";
|
"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 { 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";
|
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
|
||||||
|
|
||||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||||
@@ -14,14 +20,37 @@ function readIdentityKey(): string | null {
|
|||||||
return getAccountIdentityKey();
|
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
|
* Cross-tab auth boundary for normal pages.
|
||||||
* tabs and lets the user decide when to refresh on account switches.
|
*
|
||||||
|
* 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 lastIdentityRef = useRef<string | null>(readIdentityKey());
|
||||||
|
const [revision, setRevision] = useState(0);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (disabled) return;
|
||||||
// 嵌入(iframe)态 / 独立会话态(?isolate=1):鉴权走按标签页的 sessionStorage,本就不与其它标签页
|
// 嵌入(iframe)态 / 独立会话态(?isolate=1):鉴权走按标签页的 sessionStorage,本就不与其它标签页
|
||||||
// 共享,且不应被其它标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。
|
// 共享,且不应被其它标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。
|
||||||
if (isEmbedActive() || isIsolatedSession()) return;
|
if (isEmbedActive() || isIsolatedSession()) return;
|
||||||
@@ -36,18 +65,27 @@ export function AuthSyncWatcher() {
|
|||||||
|
|
||||||
lastIdentityRef.current = nextIdentity;
|
lastIdentityRef.current = nextIdentity;
|
||||||
|
|
||||||
|
void queryClient.cancelQueries();
|
||||||
|
queryClient.clear();
|
||||||
|
window.dispatchEvent(new Event(AUTH_STATE_CHANGED_EVENT));
|
||||||
|
setRevision((value) => value + 1);
|
||||||
|
|
||||||
if (nextIdentity === null) {
|
if (nextIdentity === null) {
|
||||||
|
toast.info("账号已在其它页面退出", {
|
||||||
|
id: IDENTITY_TOAST_ID,
|
||||||
|
description: "当前页面已同步退出状态。",
|
||||||
|
});
|
||||||
|
navigate("/login", { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.warning("检测到账号在其它标签页发生变化", {
|
const freshRoute = freshRouteForAccount(location.pathname);
|
||||||
|
if (freshRoute !== location.pathname) {
|
||||||
|
navigate(freshRoute, { replace: true });
|
||||||
|
}
|
||||||
|
toast.info("账号已在页面间自动同步", {
|
||||||
id: IDENTITY_TOAST_ID,
|
id: IDENTITY_TOAST_ID,
|
||||||
description: "当前标签页仍是原会话。如需与新账号同步,请刷新页面。",
|
description: `当前账号:${getAccountDisplayName()}。无需刷新页面。`,
|
||||||
duration: Infinity,
|
|
||||||
action: {
|
|
||||||
label: "刷新同步",
|
|
||||||
onClick: () => window.location.reload(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,14 +106,24 @@ export function AuthSyncWatcher() {
|
|||||||
settleTimer = setTimeout(evaluateSettledIdentity, SETTLE_DELAY_MS);
|
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("storage", handleStorage);
|
||||||
|
window.addEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("storage", handleStorage);
|
window.removeEventListener("storage", handleStorage);
|
||||||
|
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange);
|
||||||
if (settleTimer) {
|
if (settleTimer) {
|
||||||
clearTimeout(settleTimer);
|
clearTimeout(settleTimer);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, [disabled, location.pathname, navigate, queryClient]);
|
||||||
|
|
||||||
return null;
|
return children ? <Fragment key={revision}>{children}</Fragment> : null;
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
getAccountIdentityKey,
|
getAccountIdentityKey,
|
||||||
getStoredAuth,
|
getStoredAuth,
|
||||||
} from "@/core/auth";
|
} from "@/core/auth";
|
||||||
import { isEmbedActive } from "@/core/embed/embed-session";
|
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
|
||||||
|
|
||||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||||
|
|
||||||
@@ -65,7 +65,9 @@ export function useLiveAccount(): LiveAccount {
|
|||||||
refresh();
|
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("storage", onStorage);
|
||||||
}
|
}
|
||||||
window.addEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
|
window.addEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
|
||||||
|
|||||||
@@ -456,7 +456,7 @@ export const enUS: Translations = {
|
|||||||
lightDescription: "Bright palette with higher contrast for daytime.",
|
lightDescription: "Bright palette with higher contrast for daytime.",
|
||||||
darkDescription: "Dim palette that reduces glare for focus.",
|
darkDescription: "Dim palette that reduces glare for focus.",
|
||||||
lightblueDescription: "A crisp light palette built around blue.",
|
lightblueDescription: "A crisp light palette built around blue.",
|
||||||
lightgreenDescription: "A crisp light palette built around WeKnora green.",
|
lightgreenDescription: "A crisp light palette built around fresh green tones.",
|
||||||
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
|
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
|
||||||
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
|
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
|
||||||
languageTitle: "Language",
|
languageTitle: "Language",
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ export const zhCN: Translations = {
|
|||||||
lightDescription: "更明亮的配色,适合日间使用。",
|
lightDescription: "更明亮的配色,适合日间使用。",
|
||||||
darkDescription: "更暗的配色,减少眩光方便专注。",
|
darkDescription: "更暗的配色,减少眩光方便专注。",
|
||||||
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
||||||
lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
|
lightgreenDescription: "采用清新绿色的浅色配色。",
|
||||||
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
|
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
|
||||||
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
|
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
|
||||||
languageTitle: "语言",
|
languageTitle: "语言",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget
|
|||||||
params.get("wikiSlug")?.trim() ||
|
params.get("wikiSlug")?.trim() ||
|
||||||
params.get("slug")?.trim() ||
|
params.get("slug")?.trim() ||
|
||||||
"";
|
"";
|
||||||
return knowledgeBaseId && chunkId
|
return knowledgeBaseId && (wikiSlug || chunkId || documentId)
|
||||||
? {
|
? {
|
||||||
knowledgeBaseId,
|
knowledgeBaseId,
|
||||||
chunkId,
|
chunkId,
|
||||||
@@ -75,6 +75,7 @@ export function resolveWeKnoraSourceTarget(source: KnowledgeSourceLike): WeKnora
|
|||||||
fromPreview?.documentId ||
|
fromPreview?.documentId ||
|
||||||
"";
|
"";
|
||||||
const chunkId = source.chunk_id || source.chunkId || fromPreview?.chunkId || "";
|
const chunkId = source.chunk_id || source.chunkId || fromPreview?.chunkId || "";
|
||||||
|
if (!wikiSlug && !chunkId && !documentId) return null;
|
||||||
return {
|
return {
|
||||||
knowledgeBaseId,
|
knowledgeBaseId,
|
||||||
chunkId: chunkId || undefined,
|
chunkId: chunkId || undefined,
|
||||||
@@ -94,6 +95,43 @@ export function buildWeKnoraSourceIframeUrl(target: WeKnoraSourceTarget): string
|
|||||||
return `/#/page/workspace/knowledge/${encodeURIComponent(target.knowledgeBaseId)}${query ? `?${query}` : ""}`;
|
return `/#/page/workspace/knowledge/${encodeURIComponent(target.knowledgeBaseId)}${query ? `?${query}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight native reader for one Wiki page. Unlike the knowledge-base
|
||||||
|
* detail route this does not create a WeKnora iframe session.
|
||||||
|
*/
|
||||||
|
export function buildWeKnoraWikiPageUrl(
|
||||||
|
knowledgeBaseId: string,
|
||||||
|
wikiSlug: string,
|
||||||
|
): string {
|
||||||
|
return `/#${buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWeKnoraWikiPagePath(
|
||||||
|
knowledgeBaseId: string,
|
||||||
|
wikiSlug: string,
|
||||||
|
): string {
|
||||||
|
const params = new URLSearchParams({ wikiSlug });
|
||||||
|
return `/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): string {
|
||||||
const recUuid = source.recUuid || source.rec_uuid || source.url || "";
|
const recUuid = source.recUuid || source.rec_uuid || source.url || "";
|
||||||
if (!recUuid) return "";
|
if (!recUuid) return "";
|
||||||
@@ -108,7 +146,9 @@ export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): stri
|
|||||||
export function openKnowledgeSource(source: KnowledgeSourceLike): boolean {
|
export function openKnowledgeSource(source: KnowledgeSourceLike): boolean {
|
||||||
const weknoraTarget = resolveWeKnoraSourceTarget(source);
|
const weknoraTarget = resolveWeKnoraSourceTarget(source);
|
||||||
const href = weknoraTarget
|
const href = weknoraTarget
|
||||||
? buildWeKnoraSourceIframeUrl(weknoraTarget)
|
? weknoraTarget.wikiSlug
|
||||||
|
? buildWeKnoraWikiPageUrl(weknoraTarget.knowledgeBaseId, weknoraTarget.wikiSlug)
|
||||||
|
: buildWeKnoraSourceIframeUrl(weknoraTarget)
|
||||||
: buildLegacyKnowledgeSourceUrl(source);
|
: buildLegacyKnowledgeSourceUrl(source);
|
||||||
if (!href) return false;
|
if (!href) return false;
|
||||||
window.open(href, "_blank", "noopener,noreferrer");
|
window.open(href, "_blank", "noopener,noreferrer");
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { buildWeKnoraWikiPageUrl } from "@/core/llmwiki/source-links";
|
||||||
|
|
||||||
|
export type WikiPageUrlBuilder = (
|
||||||
|
knowledgeBaseId: string,
|
||||||
|
wikiSlug: string,
|
||||||
|
) => string;
|
||||||
|
|
||||||
|
export function stripWikiFrontmatter(value: string) {
|
||||||
|
return value.replace(/^\s*---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeWikiFileName(value: string) {
|
||||||
|
return value.replace(/[\\/:*?"<>|\r\n]+/g, "_").replace(/\s+/g, " ").trim() || "wiki-page";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportableWikiMarkdown(title: string, summary: string, content: string) {
|
||||||
|
const body = content.trim();
|
||||||
|
const hasTitle = /^#\s+/m.test(body);
|
||||||
|
return [hasTitle ? "" : `# ${title}`, summary && !body.includes(summary) ? `> ${summary}` : "", body]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wikiLinkMarkdown(
|
||||||
|
markdown: string,
|
||||||
|
knowledgeBaseId: string,
|
||||||
|
buildPageUrl: WikiPageUrlBuilder = buildWeKnoraWikiPageUrl,
|
||||||
|
) {
|
||||||
|
const link = (slugValue: string, labelValue?: string) => {
|
||||||
|
const slug = slugValue.trim();
|
||||||
|
const fallbackLabel = slug.split("/").filter(Boolean).at(-1) || slug;
|
||||||
|
const label = (labelValue || fallbackLabel).trim().replace(/\]/g, "\\]");
|
||||||
|
return `[${label}](${buildPageUrl(knowledgeBaseId, slug)})`;
|
||||||
|
};
|
||||||
|
return markdown
|
||||||
|
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, (_match, slug, label) => link(slug, label))
|
||||||
|
.replace(/\[\[([^\]\s]+)\s+([^\]]+)\]\]/g, (_match, slug, label) => link(slug, label))
|
||||||
|
.replace(/\[\[([^\]]+)\]\]/g, (_match, slug) => link(slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderableWikiMarkdown(
|
||||||
|
title: string,
|
||||||
|
content: string,
|
||||||
|
knowledgeBaseId: string,
|
||||||
|
buildPageUrl?: WikiPageUrlBuilder,
|
||||||
|
) {
|
||||||
|
const lines = content.trim().split("\n");
|
||||||
|
if (lines[0]?.replace(/^#+\s*/, "").trim() === title.trim()) {
|
||||||
|
lines[0] = `# ${title}`;
|
||||||
|
}
|
||||||
|
return wikiLinkMarkdown(
|
||||||
|
exportableWikiMarkdown(title, "", lines.join("\n")),
|
||||||
|
knowledgeBaseId,
|
||||||
|
buildPageUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import type { LlmWikiWikiPage } from "@/strategy-components/api/llmwiki";
|
||||||
|
|
||||||
|
export type WikiTreeNode = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
page: LlmWikiWikiPage | null;
|
||||||
|
children: WikiTreeNode[];
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WikiTree = {
|
||||||
|
roots: WikiTreeNode[];
|
||||||
|
parentKeysBySlug: Map<string, string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function pageDirectoryParts(page: LlmWikiWikiPage): string[] {
|
||||||
|
const wikiPath = (page.wiki_path || "").split("/").map((item) => item.trim()).filter(Boolean);
|
||||||
|
if (wikiPath.length > 2) return wikiPath.slice(1, -1);
|
||||||
|
const category = page.category_path?.filter(Boolean) || [];
|
||||||
|
if (category.length > 0) return category;
|
||||||
|
const parts = (page.slug || "").split("/").filter(Boolean);
|
||||||
|
return parts.length > 1 ? parts.slice(0, -1) : ["未分类"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterWikiPagesWithAncestors(
|
||||||
|
pages: LlmWikiWikiPage[],
|
||||||
|
query: string,
|
||||||
|
): LlmWikiWikiPage[] {
|
||||||
|
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
if (words.length === 0) return pages;
|
||||||
|
const matched = pages.filter((page) => {
|
||||||
|
const haystack = [
|
||||||
|
page.title,
|
||||||
|
page.slug,
|
||||||
|
page.summary,
|
||||||
|
page.page_type,
|
||||||
|
page.parent_slug,
|
||||||
|
...(page.aliases || []),
|
||||||
|
...(page.category_path || []),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
return words.every((word) => haystack.includes(word));
|
||||||
|
});
|
||||||
|
const bySlug = new Map(pages.map((page) => [page.slug, page]));
|
||||||
|
const result = new Map<string, LlmWikiWikiPage>();
|
||||||
|
for (const page of matched) {
|
||||||
|
let current: LlmWikiWikiPage | undefined = page;
|
||||||
|
const visited = new Set<string>();
|
||||||
|
while (current?.slug && !visited.has(current.slug)) {
|
||||||
|
visited.add(current.slug);
|
||||||
|
result.set(current.slug, current);
|
||||||
|
current = current.parent_slug ? bySlug.get(current.parent_slug) : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(result.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachNode(parent: WikiTreeNode | null, roots: WikiTreeNode[], node: WikiTreeNode) {
|
||||||
|
const siblings = parent ? parent.children : roots;
|
||||||
|
if (!siblings.some((item) => item.key === node.key)) siblings.push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortWikiNodes(nodes: WikiTreeNode[]) {
|
||||||
|
nodes.sort((left, right) => {
|
||||||
|
if (!!left.children.length !== !!right.children.length) return left.children.length ? -1 : 1;
|
||||||
|
return left.label.localeCompare(right.label, "zh-Hans-CN");
|
||||||
|
});
|
||||||
|
nodes.forEach((node) => sortWikiNodes(node.children));
|
||||||
|
}
|
||||||
|
|
||||||
|
function countPages(node: WikiTreeNode): number {
|
||||||
|
node.count = (node.page ? 1 : 0) + node.children.reduce((sum, child) => sum + countPages(child), 0);
|
||||||
|
return node.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWikiTree(pages: LlmWikiWikiPage[]): WikiTree {
|
||||||
|
const roots: WikiTreeNode[] = [];
|
||||||
|
const pageNodes = new Map<string, WikiTreeNode>();
|
||||||
|
const directoryNodes = new Map<string, WikiTreeNode>();
|
||||||
|
const parentKeysBySlug = new Map<string, string[]>();
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
if (!page.slug) continue;
|
||||||
|
pageNodes.set(page.slug, {
|
||||||
|
key: `page:${page.slug}`,
|
||||||
|
label: page.title || page.slug,
|
||||||
|
page,
|
||||||
|
children: [],
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const directoryNode = (path: string[]) => {
|
||||||
|
const key = `dir:${path.join("/")}`;
|
||||||
|
const existing = directoryNodes.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
const node: WikiTreeNode = {
|
||||||
|
key,
|
||||||
|
label: path[path.length - 1] || "未分类",
|
||||||
|
page: null,
|
||||||
|
children: [],
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
directoryNodes.set(key, node);
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
const node = pageNodes.get(page.slug);
|
||||||
|
if (!node) continue;
|
||||||
|
const ancestorKeys: string[] = [];
|
||||||
|
const parentSlug = page.parent_slug?.trim();
|
||||||
|
if (parentSlug && pageNodes.has(parentSlug)) {
|
||||||
|
const parent = pageNodes.get(parentSlug)!;
|
||||||
|
attachNode(parent, roots, node);
|
||||||
|
ancestorKeys.push(parent.key);
|
||||||
|
let current = parent.page?.parent_slug ? pageNodes.get(parent.page.parent_slug) : undefined;
|
||||||
|
const seen = new Set<string>([parent.key]);
|
||||||
|
while (current && !seen.has(current.key)) {
|
||||||
|
seen.add(current.key);
|
||||||
|
ancestorKeys.unshift(current.key);
|
||||||
|
current = current.page?.parent_slug ? pageNodes.get(current.page.parent_slug) : undefined;
|
||||||
|
}
|
||||||
|
parentKeysBySlug.set(page.slug, ancestorKeys);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent: WikiTreeNode | null = null;
|
||||||
|
const path: string[] = [];
|
||||||
|
for (const part of pageDirectoryParts(page)) {
|
||||||
|
path.push(part);
|
||||||
|
const directory = directoryNode(path);
|
||||||
|
attachNode(parent, roots, directory);
|
||||||
|
parent = directory;
|
||||||
|
ancestorKeys.push(directory.key);
|
||||||
|
}
|
||||||
|
attachNode(parent, roots, node);
|
||||||
|
parentKeysBySlug.set(page.slug, ancestorKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
roots.forEach(countPages);
|
||||||
|
sortWikiNodes(roots);
|
||||||
|
return { roots, parentKeysBySlug };
|
||||||
|
}
|
||||||
@@ -4,10 +4,11 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
|||||||
notification: {
|
notification: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
streamSpeedMeterEnabled: true,
|
streamSpeedMeterEnabled: true,
|
||||||
ragModeEnabled: false,
|
ragModeEnabled: false,
|
||||||
contextCompressionEnabled: false,
|
artifactDirectoryModeEnabled: false,
|
||||||
|
contextCompressionEnabled: false,
|
||||||
citationCleanupWords: [],
|
citationCleanupWords: [],
|
||||||
fontScale: 1,
|
fontScale: 1,
|
||||||
},
|
},
|
||||||
@@ -43,7 +44,12 @@ export interface LocalSettings {
|
|||||||
* Global switch for skill result display adapters. When disabled, chat
|
* Global switch for skill result display adapters. When disabled, chat
|
||||||
* messages render tool results with the legacy generic search UI only.
|
* messages render tool results with the legacy generic search UI only.
|
||||||
*/
|
*/
|
||||||
ragModeEnabled: boolean;
|
ragModeEnabled: boolean;
|
||||||
|
/**
|
||||||
|
* When enabled, the artifact panel scans every generated output and keeps
|
||||||
|
* a directory tree visible beside the selected file preview.
|
||||||
|
*/
|
||||||
|
artifactDirectoryModeEnabled: boolean;
|
||||||
/**
|
/**
|
||||||
* Per-user switch for context compression (上下文压缩). When enabled, long
|
* Per-user switch for context compression (上下文压缩). When enabled, long
|
||||||
* conversations are summarized transiently before the model call to save
|
* conversations are summarized transiently before the model call to save
|
||||||
|
|||||||
@@ -761,17 +761,15 @@ export function useThreadStream({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A "thread not found" 404 almost always means the URL points at a
|
// A "thread not found" 404 means this route cannot accept another turn.
|
||||||
// thread from a previous login session — either this tab outlived a
|
// Move to a fresh chat without a full browser reload; the old reload path
|
||||||
// logout/re-login or the thread genuinely belongs to a different user.
|
// made every click look like a crash and could loop indefinitely when a
|
||||||
// Re-frame the error around the *current* logged-in identity and bounce
|
// different tab's cookie kept overriding this tab's Bearer token.
|
||||||
// the user to a fresh chat instead of leaving them stuck on the stale
|
if (isThreadNotAccessibleError(message)) {
|
||||||
// route looking at a raw HTTP 404.
|
const userLabel = buildCurrentUserLabel();
|
||||||
if (isThreadNotAccessibleError(message)) {
|
toast.info(
|
||||||
const userLabel = buildCurrentUserLabel();
|
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
|
||||||
toast.info(
|
);
|
||||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,正在为你刷新页面…`,
|
|
||||||
);
|
|
||||||
// Prefer staying inside an agent chat context when the user is on an
|
// Prefer staying inside an agent chat context when the user is on an
|
||||||
// agent thread URL — otherwise fall back to the global new chat.
|
// agent thread URL — otherwise fall back to the global new chat.
|
||||||
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
||||||
@@ -782,18 +780,7 @@ export function useThreadStream({
|
|||||||
: iframeMatch
|
: iframeMatch
|
||||||
? "/page/workspace/chats_iframe/new"
|
? "/page/workspace/chats_iframe/new"
|
||||||
: "/page/workspace/chats/new";
|
: "/page/workspace/chats/new";
|
||||||
// First navigate to a safe URL so the post-reload page lands on a
|
navigate(target, { replace: true });
|
||||||
// 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 });
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 上一轮还没结束又发了新问题(409)或并发达到上限(429):
|
// 上一轮还没结束又发了新问题(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
|
* 404 just leaves the user staring at a scary error. Both the chat stream
|
||||||
* (``core/threads/hooks.ts``) and the AI-writing stream
|
* (``core/threads/hooks.ts``) and the AI-writing stream
|
||||||
* (``open-canvas/contexts/AIWritingContext.tsx``) funnel their stream errors
|
* (``open-canvas/contexts/AIWritingContext.tsx``) funnel their stream errors
|
||||||
* through here so the UX is identical: detect the login-change 404 and prompt
|
* through here so the UX is identical: detect the login-change 404 and reset
|
||||||
* the user to refresh against the current identity instead of erroring out.
|
* 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. */
|
/** 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
|
* Streams whose session id lives in component state pass a local reset callback
|
||||||
* short delay so the toast is visible and so TanStack Query cache / context
|
* so they can discard the inaccessible session without a browser reload.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export function notifyThreadAccessChangedAndReload(): void {
|
export function notifyThreadAccessChanged(onReset: () => void): void {
|
||||||
const userLabel = buildCurrentUserLabel();
|
const userLabel = buildCurrentUserLabel();
|
||||||
toast.info(
|
toast.info(
|
||||||
`当前登录用户为 ${userLabel},原会话不存在或无权访问,正在为你刷新页面…`,
|
`当前登录用户为 ${userLabel},原会话不存在或无权访问,已为你重置为新会话。`,
|
||||||
);
|
);
|
||||||
if (typeof window !== "undefined") {
|
onReset();
|
||||||
window.setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 900);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ import { getAPIClient } from '@/core/api';
|
|||||||
import {
|
import {
|
||||||
getStreamErrorMessage,
|
getStreamErrorMessage,
|
||||||
isThreadNotAccessibleError,
|
isThreadNotAccessibleError,
|
||||||
notifyThreadAccessChangedAndReload,
|
notifyThreadAccessChanged,
|
||||||
} from '@/core/threads/thread-access-error';
|
} from '@/core/threads/thread-access-error';
|
||||||
|
|
||||||
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
|
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
|
// thread belongs to a previous login session — the user switched accounts
|
||||||
// (or this tab outlived a re-login), so the current account legitimately
|
// (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
|
// 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 panel, re-frame it around the current identity and discard the
|
||||||
// the chat stream gives (see core/threads/hooks.ts).
|
// stale component-state session without reloading the browser page.
|
||||||
if (isThreadNotAccessibleError(message)) {
|
if (isThreadNotAccessibleError(message)) {
|
||||||
notifyThreadAccessChangedAndReload();
|
notifyThreadAccessChanged(() => {
|
||||||
|
continueTargetRef.current = null;
|
||||||
|
dispatchRef.current?.({ type: 'reset' });
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatchRef.current?.({ type: 'set_error', message });
|
dispatchRef.current?.({ type: 'set_error', message });
|
||||||
@@ -410,6 +413,13 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
|||||||
// (刷新会断开 SSE,后端默认 on_disconnect=cancel 取消该 run,不留孤儿)。
|
// (刷新会断开 SSE,后端默认 on_disconnect=cancel 取消该 run,不留孤儿)。
|
||||||
const threadId = state.sessionId ?? undefined;
|
const threadId = state.sessionId ?? undefined;
|
||||||
|
|
||||||
|
const resetAfterThreadAccessError = useCallback(() => {
|
||||||
|
setArtifact(null);
|
||||||
|
seenInterruptRef.current = null;
|
||||||
|
continueTargetRef.current = null;
|
||||||
|
dispatch({ type: 'reset' });
|
||||||
|
}, [setArtifact]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const s = state.status;
|
const s = state.status;
|
||||||
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
|
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
|
||||||
@@ -473,12 +483,12 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = getStreamErrorMessage(err);
|
const message = getStreamErrorMessage(err);
|
||||||
if (isThreadNotAccessibleError(message)) {
|
if (isThreadNotAccessibleError(message)) {
|
||||||
notifyThreadAccessChangedAndReload();
|
notifyThreadAccessChanged(resetAfterThreadAccessError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch({ type: 'set_error', message });
|
dispatch({ type: 'set_error', message });
|
||||||
}
|
}
|
||||||
}, [state.status]);
|
}, [state.status, resetAfterThreadAccessError]);
|
||||||
|
|
||||||
const submitIntervention = useCallback(async (payload: UserInterventionPayload) => {
|
const submitIntervention = useCallback(async (payload: UserInterventionPayload) => {
|
||||||
if (!state.sessionId || !state.currentPause) return;
|
if (!state.sessionId || !state.currentPause) return;
|
||||||
@@ -585,14 +595,14 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = getStreamErrorMessage(err);
|
const message = getStreamErrorMessage(err);
|
||||||
if (isThreadNotAccessibleError(message)) {
|
if (isThreadNotAccessibleError(message)) {
|
||||||
notifyThreadAccessChangedAndReload();
|
notifyThreadAccessChanged(resetAfterThreadAccessError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch({ type: 'set_error', message });
|
dispatch({ type: 'set_error', message });
|
||||||
}
|
}
|
||||||
}, [state.sessionId, state.currentPause, state.materials, state.outline,
|
}, [state.sessionId, state.currentPause, state.materials, state.outline,
|
||||||
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
|
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
|
||||||
state.status]);
|
state.status, resetAfterThreadAccessError]);
|
||||||
|
|
||||||
const pauseWriting = useCallback(() => {
|
const pauseWriting = useCallback(() => {
|
||||||
// 真停:取消当前 run(graph 已完成的阶段已落 checkpoint)。继续时从该 checkpoint 续跑。
|
// 真停:取消当前 run(graph 已完成的阶段已落 checkpoint)。继续时从该 checkpoint 续跑。
|
||||||
@@ -626,13 +636,13 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = getStreamErrorMessage(err);
|
const message = getStreamErrorMessage(err);
|
||||||
if (isThreadNotAccessibleError(message)) {
|
if (isThreadNotAccessibleError(message)) {
|
||||||
notifyThreadAccessChangedAndReload();
|
notifyThreadAccessChanged(resetAfterThreadAccessError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch({ type: 'set_error', message });
|
dispatch({ type: 'set_error', message });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [state.request]);
|
}, [state.request, resetAfterThreadAccessError]);
|
||||||
|
|
||||||
// Restore an unfinished history session: make it active, load its checkpoint, then let
|
// 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.
|
// the host skip completed awaiting_* stages or re-run the current unfinished stage.
|
||||||
|
|||||||
@@ -776,6 +776,17 @@ export default function AgentChatPage({
|
|||||||
onContextChange={(context) =>
|
onContextChange={(context) =>
|
||||||
setSettings("context", context)
|
setSettings("context", context)
|
||||||
}
|
}
|
||||||
|
knowledgeSelector={
|
||||||
|
<LlmWikiKnowledgeSelector
|
||||||
|
source="agent"
|
||||||
|
agentId={agent_id}
|
||||||
|
value={selectedKnowledgeBaseIds}
|
||||||
|
onChange={changeKnowledgeSelection}
|
||||||
|
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||||
|
readOnly
|
||||||
|
label="知识空间"
|
||||||
|
/>
|
||||||
|
}
|
||||||
onFollowupsVisibilityChange={setShowFollowups}
|
onFollowupsVisibilityChange={setShowFollowups}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onStop={handleStop}
|
onStop={handleStop}
|
||||||
|
|||||||
@@ -838,20 +838,19 @@ export default function ChatPage() {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
knowledgeSelector={
|
knowledgeSelector={
|
||||||
isLandingState ? (
|
<LlmWikiKnowledgeSelector
|
||||||
<LlmWikiKnowledgeSelector
|
source={selectedAgentId ? "agent" : "all"}
|
||||||
source={selectedAgentId ? "agent" : "all"}
|
agentId={selectedAgentId}
|
||||||
agentId={selectedAgentId}
|
value={selectedKnowledgeBaseIds}
|
||||||
value={selectedKnowledgeBaseIds}
|
onChange={changeKnowledgeSelection}
|
||||||
onChange={changeKnowledgeSelection}
|
autoSelectAll={isLandingState && !!selectedAgentId}
|
||||||
autoSelectAll={!!selectedAgentId}
|
disabled={
|
||||||
disabled={
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
knowledgeSelectionLoading
|
||||||
knowledgeSelectionLoading
|
}
|
||||||
}
|
readOnly={!isLandingState}
|
||||||
label="知识空间"
|
label="知识空间"
|
||||||
/>
|
/>
|
||||||
) : undefined
|
|
||||||
}
|
}
|
||||||
onFollowupsVisibilityChange={setShowFollowups}
|
onFollowupsVisibilityChange={setShowFollowups}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ import { cn } from "@/lib/utils";
|
|||||||
import { openKnowledgeSource } from "@/core/llmwiki/source-links";
|
import { openKnowledgeSource } from "@/core/llmwiki/source-links";
|
||||||
import MarkdownRenderer from "@/strategy-components/MarkdownRenderer";
|
import MarkdownRenderer from "@/strategy-components/MarkdownRenderer";
|
||||||
import {
|
import {
|
||||||
type LlmWikiSearchResult,
|
|
||||||
useLlmWikiKnowledgeBases,
|
useLlmWikiKnowledgeBases,
|
||||||
useLlmWikiRuntime,
|
useLlmWikiRuntime,
|
||||||
} from "@/strategy-components/api/llmwiki";
|
} from "@/strategy-components/api/llmwiki";
|
||||||
import {
|
import {
|
||||||
|
type EnterpriseResearchEvidence,
|
||||||
type EnterpriseResearchTask,
|
type EnterpriseResearchTask,
|
||||||
type EnterpriseResearchTaskStatus,
|
type EnterpriseResearchTaskStatus,
|
||||||
type EnterpriseResearchReportEvent,
|
type EnterpriseResearchReportEvent,
|
||||||
@@ -636,7 +636,7 @@ function EvidencePane({
|
|||||||
onStartReport, onCancelReport, onRestoreReport, onSourceSelect,
|
onStartReport, onCancelReport, onRestoreReport, onSourceSelect,
|
||||||
}: {
|
}: {
|
||||||
task: EnterpriseResearchTask | null;
|
task: EnterpriseResearchTask | null;
|
||||||
source: LlmWikiSearchResult | null;
|
source: EnterpriseResearchEvidence | null;
|
||||||
reportJob: EnterpriseResearchReportJob | null;
|
reportJob: EnterpriseResearchReportJob | null;
|
||||||
reportJobs: EnterpriseResearchReportJob[];
|
reportJobs: EnterpriseResearchReportJob[];
|
||||||
reportDraft: string;
|
reportDraft: string;
|
||||||
@@ -665,7 +665,7 @@ function EvidencePane({
|
|||||||
const latestRewrite = reportJobs.find((job) => job.action === "rewrite" && job.status === "completed" && job.original_report);
|
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 latestCommittedJob = reportJobs.find((job) => job.action !== "followup" && job.status === "completed");
|
||||||
const completedFollowups = reportJobs.filter((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 citationIndex = new Map(selectedSources.map((item, index) => [item.chunk_id, index + 1]));
|
||||||
const renderedReport = currentReport.replace(/\[资料:([^\]]+)\]/g, (match, id: string) => {
|
const renderedReport = currentReport.replace(/\[资料:([^\]]+)\]/g, (match, id: string) => {
|
||||||
const index = citationIndex.get(id.trim());
|
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 { Loader2, Settings2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
|
||||||
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||||
import WeKnoraKnowledgeWorkspace, {
|
import WeKnoraKnowledgeWorkspace, {
|
||||||
ConnectionSettings,
|
ConnectionSettings,
|
||||||
@@ -66,7 +67,14 @@ export default function LlmWikiKnowledgePage({
|
|||||||
}: {
|
}: {
|
||||||
mode?: LlmWikiKnowledgePageMode;
|
mode?: LlmWikiKnowledgePageMode;
|
||||||
}) {
|
}) {
|
||||||
|
const { noteId = "" } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const runtime = useLlmWikiRuntime();
|
const runtime = useLlmWikiRuntime();
|
||||||
|
const wikiSlug = searchParams.get("wikiSlug")?.trim() || "";
|
||||||
|
|
||||||
|
if (mode === "detail" && noteId && wikiSlug) {
|
||||||
|
return <Navigate to={buildWeKnoraWikiPagePath(noteId, wikiSlug)} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
if (runtime.isLoading) {
|
if (runtime.isLoading) {
|
||||||
return <RuntimeLoading />;
|
return <RuntimeLoading />;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import LightAppListPage from "./LightAppListPage";
|
|||||||
import LightAppManagementPage from "./LightAppManagementPage";
|
import LightAppManagementPage from "./LightAppManagementPage";
|
||||||
import MenuManagementPage from "./MenuManagementPage";
|
import MenuManagementPage from "./MenuManagementPage";
|
||||||
import LoginWhitelistPage from "./LoginWhitelistPage";
|
import LoginWhitelistPage from "./LoginWhitelistPage";
|
||||||
|
import FixedQuestionsPage from "./FixedQuestionsPage";
|
||||||
import SensitiveWordsPage from "./SensitiveWordsPage";
|
import SensitiveWordsPage from "./SensitiveWordsPage";
|
||||||
import SkillAddressesPage from "./SkillAddressesPage";
|
import SkillAddressesPage from "./SkillAddressesPage";
|
||||||
import TaskButtonsPage from "./TaskButtonsPage";
|
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
|
<Route
|
||||||
path="admin/sensitive-words"
|
path="admin/sensitive-words"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
|
|||||||
import ScheduledTasksPage from './ScheduledTasksPage';
|
import ScheduledTasksPage from './ScheduledTasksPage';
|
||||||
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
|
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
|
||||||
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
|
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
|
||||||
|
import WeKnoraWikiPageView from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView';
|
||||||
import MarkdownDocxEditorPage from './MarkdownDocxEditorPage';
|
import MarkdownDocxEditorPage from './MarkdownDocxEditorPage';
|
||||||
import LlmMetricsPage from './LlmMetricsPage';
|
import LlmMetricsPage from './LlmMetricsPage';
|
||||||
import CheckpointMigrationPage from './CheckpointMigrationPage';
|
import CheckpointMigrationPage from './CheckpointMigrationPage';
|
||||||
@@ -678,6 +679,14 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="knowledge/:noteId/wiki/view"
|
||||||
|
element={
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<WeKnoraWikiPageView />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="knowledge/:noteId/wiki"
|
path="knowledge/:noteId/wiki"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import {
|
|||||||
} from "@/strategy-components/api/report-structures";
|
} from "@/strategy-components/api/report-structures";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// ── shared helpers ───────────────────────────────────────────────────────────
|
// ── shared helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1213,12 +1214,16 @@ export function WorkflowConfigButton({
|
|||||||
return structuresQuery.data?.find((structure) => structure.content.trim() === content)?.title ?? null;
|
return structuresQuery.data?.find((structure) => structure.content.trim() === content)?.title ?? null;
|
||||||
}, [appliedContent, structuresQuery.data]);
|
}, [appliedContent, structuresQuery.data]);
|
||||||
const buttonLabel = selectedWorkflowTitle || "工作流配置";
|
const buttonLabel = selectedWorkflowTitle || "工作流配置";
|
||||||
|
const isSelected = Boolean(selectedWorkflowTitle);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
|
aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
|
||||||
className={className}
|
className={cn(
|
||||||
|
className,
|
||||||
|
isSelected && "bg-accent text-accent-foreground dark:bg-accent/50",
|
||||||
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
size={size}
|
size={size}
|
||||||
|
|||||||
@@ -452,6 +452,11 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
|||||||
// 跨 worker 单飞——重复 / 双击命中既有回合即幂等返回,杜绝并发下双发意图。
|
// 跨 worker 单飞——重复 / 双击命中既有回合即幂等返回,杜绝并发下双发意图。
|
||||||
const clientTurnId = createClientId();
|
const clientTurnId = createClientId();
|
||||||
|
|
||||||
|
// ask_clarification 是本轮的严格暂停点。某些模型在工具调用已经产出
|
||||||
|
// 选项卡后还会继续吐一段“请做选择”等正文;从第一张卡出现起忽略后续
|
||||||
|
// 文本 delta,但继续消费流以收齐同轮的其它澄清卡和终态帧。
|
||||||
|
let clarificationSeen = false;
|
||||||
|
|
||||||
const res = await streamIntent({
|
const res = await streamIntent({
|
||||||
threadId: intentThreadId,
|
threadId: intentThreadId,
|
||||||
message: trimmed,
|
message: trimmed,
|
||||||
@@ -462,6 +467,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
|||||||
files: fileRefs,
|
files: fileRefs,
|
||||||
signal: aborter.signal,
|
signal: aborter.signal,
|
||||||
onTextDelta: (delta) => {
|
onTextDelta: (delta) => {
|
||||||
|
if (clarificationSeen) return;
|
||||||
setInteractiveMessages((prev) =>
|
setInteractiveMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === agentMsgId
|
m.id === agentMsgId
|
||||||
@@ -504,6 +510,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
onClarification: (clarification, index) => {
|
onClarification: (clarification, index) => {
|
||||||
|
clarificationSeen = true;
|
||||||
// 实时把每条澄清问题灌进气泡 —— 用户能边流边看到「协助过程」逐个出现,
|
// 实时把每条澄清问题灌进气泡 —— 用户能边流边看到「协助过程」逐个出现,
|
||||||
// 而不是等终态帧(终态帧只带第 1 个问题)。同时往步骤链补一条
|
// 而不是等终态帧(终态帧只带第 1 个问题)。同时往步骤链补一条
|
||||||
// 「追问 N:<问题>」,与「强化步骤链」诉求对齐。
|
// 「追问 N:<问题>」,与「强化步骤链」诉求对齐。
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
import type { LlmWikiSearchResult } from "./llmwiki";
|
|
||||||
|
|
||||||
const API = () => `${getBackendBaseURL()}/api/enterprise-research`;
|
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 type EnterpriseResearchTaskStatus = "draft" | "collecting" | "ready" | "partial" | "failed";
|
||||||
|
|
||||||
export interface EnterpriseResearchTask {
|
export interface EnterpriseResearchTask {
|
||||||
@@ -15,7 +31,7 @@ export interface EnterpriseResearchTask {
|
|||||||
knowledge_base_ids: string[];
|
knowledge_base_ids: string[];
|
||||||
queries: string[];
|
queries: string[];
|
||||||
selected_source_ids: string[];
|
selected_source_ids: string[];
|
||||||
sources: LlmWikiSearchResult[];
|
sources: EnterpriseResearchEvidence[];
|
||||||
plan_markdown?: string | null;
|
plan_markdown?: string | null;
|
||||||
report_markdown?: string | null;
|
report_markdown?: string | null;
|
||||||
report_summary?: 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";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
const API = () => `${getBackendBaseURL()}/api/llmwiki`;
|
const API = () => `${getBackendBaseURL()}/api/llmwiki`;
|
||||||
|
const PUBLIC_API = () => `${getBackendBaseURL()}/api/public/llmwiki`;
|
||||||
const SETTINGS_API = () => `${getBackendBaseURL()}/api/system-settings/llmwiki`;
|
const SETTINGS_API = () => `${getBackendBaseURL()}/api/system-settings/llmwiki`;
|
||||||
|
|
||||||
export type LlmWikiProvider = "legacy" | "weknora";
|
export type LlmWikiProvider = "legacy" | "weknora";
|
||||||
@@ -143,6 +144,22 @@ export interface LlmWikiWikiPageList {
|
|||||||
total_pages: number;
|
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 {
|
export interface LlmWikiSourceContext {
|
||||||
knowledge_base_name?: string;
|
knowledge_base_name?: string;
|
||||||
document_title?: string;
|
document_title?: string;
|
||||||
@@ -159,16 +176,38 @@ export interface LlmWikiSourceContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LlmWikiSearchResult {
|
export interface LlmWikiSearchResult {
|
||||||
chunk_id: string;
|
kind: "wiki_page";
|
||||||
|
id: string;
|
||||||
|
wiki_page_id: string;
|
||||||
|
wiki_slug: string;
|
||||||
content: string;
|
content: string;
|
||||||
knowledge_id: string;
|
|
||||||
knowledge_base_id: string;
|
knowledge_base_id: string;
|
||||||
knowledge_base_name: string;
|
knowledge_base_name: string;
|
||||||
title: string;
|
title: string;
|
||||||
filename: string;
|
summary?: string;
|
||||||
|
page_type?: string;
|
||||||
|
page_version?: number;
|
||||||
|
updated_at?: string | null;
|
||||||
|
matched_heading?: string | null;
|
||||||
|
matched_sections?: Array<{
|
||||||
|
section_index: number;
|
||||||
|
heading?: string | null;
|
||||||
|
content: string;
|
||||||
|
score: number;
|
||||||
|
}>;
|
||||||
score: number;
|
score: number;
|
||||||
chunk_index: number;
|
|
||||||
url: string;
|
url: string;
|
||||||
|
retrieval_mode?: "vector" | "wiki_api";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LlmWikiSearchResponse {
|
||||||
|
query: string;
|
||||||
|
mode: "vector" | "wiki_api" | "mixed";
|
||||||
|
results: LlmWikiSearchResult[];
|
||||||
|
knowledge_base_count: number;
|
||||||
|
partial: boolean;
|
||||||
|
warnings: string[];
|
||||||
|
fallback_knowledge_base_ids: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LlmWikiWeKnoraEmbedSession {
|
export interface LlmWikiWeKnoraEmbedSession {
|
||||||
@@ -203,14 +242,22 @@ export interface LlmWikiWikiPageUpdate {
|
|||||||
wiki_path?: string;
|
wiki_path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function userFacingKnowledgeMessage(value: string): string {
|
||||||
|
return value.replace(/weknora/gi, "知识服务");
|
||||||
|
}
|
||||||
|
|
||||||
async function readJson<T>(response: Response, context: string): Promise<T> {
|
async function readJson<T>(response: Response, context: string): Promise<T> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const payload = await response.json().catch(() => null as unknown);
|
const payload = await response.json().catch(() => null as unknown);
|
||||||
const detail =
|
const rawDetail = payload && typeof payload === "object" && "detail" in payload
|
||||||
payload && typeof payload === "object" && "detail" in payload
|
? (payload as { detail: unknown }).detail
|
||||||
? String((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})`;
|
: `${context}失败(${response.status})`;
|
||||||
throw new Error(detail);
|
throw new Error(userFacingKnowledgeMessage(detail));
|
||||||
}
|
}
|
||||||
if (response.status === 204) return undefined as T;
|
if (response.status === 204) return undefined as T;
|
||||||
return (await response.json()) as T;
|
return (await response.json()) as T;
|
||||||
@@ -266,7 +313,7 @@ export async function createLlmWikiWeKnoraEmbedSession(
|
|||||||
`${API()}/knowledge-bases/${encodeURIComponent(id)}/weknora/session?tab=${encodeURIComponent(tab)}`,
|
`${API()}/knowledge-bases/${encodeURIComponent(id)}/weknora/session?tab=${encodeURIComponent(tab)}`,
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
),
|
),
|
||||||
"创建 WeKnora 详情会话",
|
"创建知识库详情会话",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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(
|
export async function createLlmWikiWikiPage(
|
||||||
knowledgeBaseId: string,
|
knowledgeBaseId: string,
|
||||||
input: LlmWikiWikiPageCreate,
|
input: LlmWikiWikiPageCreate,
|
||||||
@@ -554,8 +661,8 @@ export async function searchLlmWiki(input: {
|
|||||||
query: string;
|
query: string;
|
||||||
scope: LlmWikiScope;
|
scope: LlmWikiScope;
|
||||||
knowledge_base_ids?: string[];
|
knowledge_base_ids?: string[];
|
||||||
}): Promise<LlmWikiSearchResult[]> {
|
}): Promise<LlmWikiSearchResponse> {
|
||||||
const payload = await readJson<{ results: LlmWikiSearchResult[] }>(
|
return readJson<LlmWikiSearchResponse>(
|
||||||
await apiFetch(`${API()}/search`, {
|
await apiFetch(`${API()}/search`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -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: {
|
export async function createLlmWikiConversationDeposit(input: {
|
||||||
@@ -586,7 +730,7 @@ export async function createLlmWikiConversationDeposit(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getLlmWikiSettings(): Promise<LlmWikiSettings> {
|
export async function getLlmWikiSettings(): Promise<LlmWikiSettings> {
|
||||||
return readJson(await apiFetch(SETTINGS_API()), "读取 WeKnora 连接配置");
|
return readJson(await apiFetch(SETTINGS_API()), "读取知识服务连接配置");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveLlmWikiSettings(input: {
|
export async function saveLlmWikiSettings(input: {
|
||||||
@@ -600,7 +744,7 @@ export async function saveLlmWikiSettings(input: {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
}),
|
}),
|
||||||
"保存 WeKnora 连接配置",
|
"保存知识服务连接配置",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,7 +764,7 @@ export async function testLlmWikiConnection(
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ api_base_url: apiBaseUrl }),
|
body: JSON.stringify({ api_base_url: apiBaseUrl }),
|
||||||
}),
|
}),
|
||||||
"测试 WeKnora 连接",
|
"测试知识服务连接",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) {
|
export function useLlmWikiSettings(enabled = true) {
|
||||||
const { userId } = useLiveAccount();
|
const { userId } = useLiveAccount();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -822,6 +1022,14 @@ export function useLlmWikiActions() {
|
|||||||
deleteLlmWikiWikiPage(id, slug),
|
deleteLlmWikiWikiPage(id, slug),
|
||||||
onSuccess: invalidateKnowledge,
|
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({
|
publication: useMutation({
|
||||||
mutationFn: ({ id, publish }: { id: string; publish: boolean }) =>
|
mutationFn: ({ id, publish }: { id: string; publish: boolean }) =>
|
||||||
setLlmWikiPublication(id, publish),
|
setLlmWikiPublication(id, publish),
|
||||||
|
|||||||
+16
-66
@@ -1,4 +1,4 @@
|
|||||||
import { AlertCircle, Edit3, FileText, Loader2, RefreshCw, Trash2, Upload } from "lucide-react";
|
import { AlertCircle, Edit3, FileText, Loader2, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { MarkdownCodeEditor } from "@/components/wiki/MarkdownCodeEditor";
|
import { MarkdownCodeEditor } from "@/components/wiki/MarkdownCodeEditor";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
|
||||||
import {
|
import {
|
||||||
createLlmWikiWeKnoraEmbedSession,
|
createLlmWikiWeKnoraEmbedSession,
|
||||||
getLlmWikiSourceContext,
|
getLlmWikiSourceContext,
|
||||||
@@ -100,14 +101,11 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [frameSrc, setFrameSrc] = useState("");
|
const [frameSrc, setFrameSrc] = useState("");
|
||||||
const [frameVersion, setFrameVersion] = useState(0);
|
const [frameVersion, setFrameVersion] = useState(0);
|
||||||
const [frameError, setFrameError] = useState<string | null>(null);
|
const [frameError, setFrameError] = useState<string | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const runtime = useLlmWikiRuntime();
|
const runtime = useLlmWikiRuntime();
|
||||||
const details = useLlmWikiKnowledgeBase(knowledgeBaseId || null, !!knowledgeBaseId);
|
const details = useLlmWikiKnowledgeBase(knowledgeBaseId || null, !!knowledgeBaseId);
|
||||||
const actions = useLlmWikiActions();
|
|
||||||
|
|
||||||
const detailsReady = !!details.data;
|
const detailsReady = !!details.data;
|
||||||
const requestedTab = searchParams.get("tab");
|
const requestedTab = searchParams.get("tab");
|
||||||
@@ -120,12 +118,15 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
const sourceChunkId = searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
|
const sourceChunkId = searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
|
||||||
const documentId = searchParams.get("documentId") || "";
|
const documentId = searchParams.get("documentId") || "";
|
||||||
const wikiSlug = searchParams.get("wikiSlug") || "";
|
const wikiSlug = searchParams.get("wikiSlug") || "";
|
||||||
|
const nativeWikiPageUrl = knowledgeBaseId && wikiSlug && preferredTab === "wiki"
|
||||||
|
? buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)
|
||||||
|
: "";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setFrameSrc("");
|
setFrameSrc("");
|
||||||
setFrameError(null);
|
setFrameError(null);
|
||||||
if (!knowledgeBaseId || runtime.data?.provider !== "weknora" || !detailsReady) {
|
if (nativeWikiPageUrl || !knowledgeBaseId || runtime.data?.provider !== "weknora" || !detailsReady) {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
@@ -158,7 +159,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [detailsReady, documentId, knowledgeBaseId, preferredTab, runtime.data?.provider, sourceChunkId, wikiSlug]);
|
}, [detailsReady, documentId, knowledgeBaseId, nativeWikiPageUrl, preferredTab, runtime.data?.provider, sourceChunkId, wikiSlug]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!frameSrc) return;
|
if (!frameSrc) return;
|
||||||
@@ -178,6 +179,10 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
};
|
};
|
||||||
}, [frameSrc]);
|
}, [frameSrc]);
|
||||||
|
|
||||||
|
if (nativeWikiPageUrl) {
|
||||||
|
return <Navigate to={nativeWikiPageUrl} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
if (runtime.isLoading || details.isLoading) {
|
if (runtime.isLoading || details.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
@@ -225,53 +230,15 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const canUpload = details.data.can_write && !details.data.is_conversation_deposit;
|
const canWrite = details.data.can_write && !details.data.is_conversation_deposit;
|
||||||
const canEditCurrentWikiArticle =
|
const canEditCurrentWikiArticle =
|
||||||
canUpload && !!details.data.indexing_strategy?.wiki_enabled && !!wikiSlug;
|
canWrite && !!details.data.indexing_strategy?.wiki_enabled && !!wikiSlug;
|
||||||
const canManageWiki = canUpload && !!details.data.indexing_strategy?.wiki_enabled;
|
const canManageWiki = canWrite && !!details.data.indexing_strategy?.wiki_enabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||||
{(canUpload || canEditCurrentWikiArticle) && (
|
{canManageWiki && (
|
||||||
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
|
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={(event) => {
|
|
||||||
const files = Array.from(event.currentTarget.files ?? []);
|
|
||||||
event.currentTarget.value = "";
|
|
||||||
if (files.length === 0) return;
|
|
||||||
void (async () => {
|
|
||||||
setUploading(true);
|
|
||||||
const failed: string[] = [];
|
|
||||||
try {
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
await actions.upload.mutateAsync({ id: knowledgeBaseId, file });
|
|
||||||
} catch {
|
|
||||||
failed.push(file.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const succeeded = files.length - failed.length;
|
|
||||||
if (succeeded > 0) {
|
|
||||||
await details.refetch();
|
|
||||||
setFrameVersion((value) => value + 1);
|
|
||||||
}
|
|
||||||
if (failed.length === 0) {
|
|
||||||
toast.success(`已上传 ${succeeded} 个文件,正在处理`);
|
|
||||||
} else if (succeeded > 0) {
|
|
||||||
toast.warning(`上传完成:成功 ${succeeded} 个,失败 ${failed.length} 个`);
|
|
||||||
} else {
|
|
||||||
toast.error("文件上传失败");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{canEditCurrentWikiArticle && (
|
{canEditCurrentWikiArticle && (
|
||||||
<CurrentWikiArticleActions
|
<CurrentWikiArticleActions
|
||||||
@@ -288,7 +255,6 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{canManageWiki && (
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -297,22 +263,6 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
<FileText className="mr-2 size-4" />
|
<FileText className="mr-2 size-4" />
|
||||||
Wiki管理
|
Wiki管理
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
{canUpload && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={uploading}
|
|
||||||
>
|
|
||||||
{uploading ? (
|
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Upload className="mr-2 size-4" />
|
|
||||||
)}
|
|
||||||
上传文件
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -323,7 +273,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
title={`${details.data.name || "知识库"} 详情`}
|
title={`${details.data.name || "知识库"} 详情`}
|
||||||
src={frameSrc}
|
src={frameSrc}
|
||||||
className="h-full min-h-0 w-full flex-1 border-0 bg-background"
|
className="h-full min-h-0 w-full flex-1 border-0 bg-background"
|
||||||
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-popups"
|
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-popups allow-popups-to-escape-sandbox"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
onLoad={() => postThemeToFrame(frameRef.current)}
|
onLoad={() => postThemeToFrame(frameRef.current)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+141
-15
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
BookOpenText,
|
||||||
|
ChevronRight,
|
||||||
Database,
|
Database,
|
||||||
FileText,
|
FileText,
|
||||||
Globe2,
|
Globe2,
|
||||||
@@ -29,8 +31,11 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||||
import {
|
import {
|
||||||
type LlmWikiKnowledgeBase,
|
type LlmWikiKnowledgeBase,
|
||||||
|
type LlmWikiSearchResponse,
|
||||||
|
type LlmWikiSearchResult,
|
||||||
type LlmWikiScope,
|
type LlmWikiScope,
|
||||||
searchLlmWiki,
|
searchLlmWiki,
|
||||||
useLlmWikiActions,
|
useLlmWikiActions,
|
||||||
@@ -53,6 +58,49 @@ function messageOf(error: unknown): string {
|
|||||||
return error instanceof Error ? error.message : "操作失败,请稍后重试";
|
return error instanceof Error ? error.message : "操作失败,请稍后重试";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function searchModeLabel(mode: LlmWikiSearchResponse["mode"]) {
|
||||||
|
if (mode === "vector") return "本地 Wiki 向量检索";
|
||||||
|
if (mode === "mixed") return "Wiki 混合检索";
|
||||||
|
return "Wiki 接口检索";
|
||||||
|
}
|
||||||
|
|
||||||
|
function wikiPageTypeLabel(value?: string) {
|
||||||
|
const normalized = (value || "").trim().toLowerCase();
|
||||||
|
if (normalized === "directory") return "目录页";
|
||||||
|
if (normalized === "overview") return "概览页";
|
||||||
|
if (normalized === "article") return "文章页";
|
||||||
|
return value?.trim() || "Wiki 页面";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSearchTime(value?: string | null) {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime())
|
||||||
|
? value
|
||||||
|
: date.toLocaleString("zh-CN", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultPreview(result: LlmWikiSearchResult) {
|
||||||
|
const text = (
|
||||||
|
result.matched_sections?.map((section) => section.content).filter(Boolean).join("\n\n") ||
|
||||||
|
result.summary ||
|
||||||
|
result.content ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
return text
|
||||||
|
.replace(/^---[\s\S]*?---\s*/m, "")
|
||||||
|
.replace(/```[\s\S]*?```/g, " ")
|
||||||
|
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||||
|
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
|
||||||
|
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
||||||
|
.replace(/^#{1,6}\s+/gm, "")
|
||||||
|
.replace(/^\s*[-*+]\s+/gm, "")
|
||||||
|
.replace(/[>*_`~|]/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function WeKnoraKnowledgeWorkspace() {
|
export function WeKnoraKnowledgeWorkspace() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
@@ -63,15 +111,13 @@ export function WeKnoraKnowledgeWorkspace() {
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<Array<{
|
const [searchResponse, setSearchResponse] = useState<LlmWikiSearchResponse | null>(null);
|
||||||
chunk_id: string;
|
const [selectedSearchResult, setSelectedSearchResult] = useState<LlmWikiSearchResult | null>(null);
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
score: number;
|
|
||||||
knowledge_base_name: string;
|
|
||||||
}>>([]);
|
|
||||||
|
|
||||||
useEffect(() => setSearchResults([]), [tab]);
|
useEffect(() => {
|
||||||
|
setSearchResponse(null);
|
||||||
|
setSelectedSearchResult(null);
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
const busy = actions.create.isPending || actions.remove.isPending || actions.publication.isPending;
|
const busy = actions.create.isPending || actions.remove.isPending || actions.publication.isPending;
|
||||||
const openDetails = (item: LlmWikiKnowledgeBase) => {
|
const openDetails = (item: LlmWikiKnowledgeBase) => {
|
||||||
@@ -87,7 +133,7 @@ export function WeKnoraKnowledgeWorkspace() {
|
|||||||
if (!query) return;
|
if (!query) return;
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
try {
|
try {
|
||||||
setSearchResults(await searchLlmWiki({ query, scope }));
|
setSearchResponse(await searchLlmWiki({ query, scope }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(messageOf(error));
|
toast.error(messageOf(error));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -154,9 +200,9 @@ export function WeKnoraKnowledgeWorkspace() {
|
|||||||
<div className="flex w-full max-w-xl items-center gap-2 rounded-xl border bg-background/80 p-1 shadow-sm sm:w-auto">
|
<div className="flex w-full max-w-xl items-center gap-2 rounded-xl border bg-background/80 p-1 shadow-sm sm:w-auto">
|
||||||
<div className="relative min-w-0 flex-1 sm:w-80">
|
<div className="relative min-w-0 flex-1 sm:w-80">
|
||||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input value={searchText} onChange={(event) => setSearchText(event.target.value)} onKeyDown={(event) => event.key === "Enter" && void runSearch()} placeholder={tab === "public" ? "检索公共知识" : "检索我的知识"} className="border-0 bg-transparent pl-9 shadow-none focus-visible:ring-0" />
|
<Input value={searchText} onChange={(event) => setSearchText(event.target.value)} onKeyDown={(event) => event.key === "Enter" && void runSearch()} placeholder={tab === "public" ? "检索公共 Wiki 文档" : "检索我的 Wiki 文档"} className="border-0 bg-transparent pl-9 shadow-none focus-visible:ring-0" />
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" className="rounded-lg px-4" onClick={() => void runSearch()} disabled={searching || !searchText.trim()}>{searching ? <Loader2 className="size-4 animate-spin" /> : "检索"}</Button>
|
<Button variant="secondary" className="rounded-lg px-4" onClick={() => void runSearch()} disabled={searching || !searchText.trim()}>{searching ? <Loader2 className="size-4 animate-spin" /> : "检索 Wiki"}</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -166,15 +212,95 @@ export function WeKnoraKnowledgeWorkspace() {
|
|||||||
{isAdmin && <TabsContent value="settings" className="mt-5"><ConnectionSettings /></TabsContent>}
|
{isAdmin && <TabsContent value="settings" className="mt-5"><ConnectionSettings /></TabsContent>}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{searchResults.length > 0 && tab !== "settings" && (
|
{searchResponse && tab !== "settings" && (
|
||||||
<Card className="mt-5">
|
<Card className="mt-5 overflow-hidden border-emerald-200/70 bg-gradient-to-br from-emerald-50/70 via-card to-card shadow-sm dark:border-emerald-900/60 dark:from-emerald-950/20">
|
||||||
<CardHeader className="pb-3"><CardTitle className="text-base">检索结果</CardTitle><CardDescription>结果由知识服务检索返回,问答中使用同一授权链路。</CardDescription></CardHeader>
|
<CardHeader className="border-b border-emerald-200/60 pb-4 dark:border-emerald-900/50">
|
||||||
<CardContent className="space-y-3">{searchResults.map((result) => <div key={result.chunk_id} className="rounded-lg border p-3"><div className="flex items-center justify-between gap-3 text-sm font-medium"><span>{result.title || "未命名来源"}</span><Badge variant="outline">{result.knowledge_base_name}</Badge></div><p className="mt-2 line-clamp-3 whitespace-pre-wrap text-sm text-muted-foreground">{result.content}</p></div>)}</CardContent>
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-emerald-600 text-white shadow-sm shadow-emerald-600/20">
|
||||||
|
<BookOpenText className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
|
||||||
|
Wiki 文档检索
|
||||||
|
<Badge className="border-emerald-200 bg-emerald-100 text-emerald-800 hover:bg-emerald-100 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">
|
||||||
|
{searchResponse.results.length} 个 Wiki 页面
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1.5 leading-5">
|
||||||
|
本次直接检索知识库生成的 Wiki 页面,不检索上传原文或原文分块。
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="shrink-0 bg-background/80 text-emerald-700 dark:text-emerald-300">
|
||||||
|
{searchModeLabel(searchResponse.mode)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 p-4">
|
||||||
|
{searchResponse.results.length === 0 ? (
|
||||||
|
<div className="flex min-h-36 flex-col items-center justify-center rounded-xl border border-dashed bg-background/70 px-6 text-center">
|
||||||
|
<BookOpenText className="mb-3 size-8 text-muted-foreground/60" />
|
||||||
|
<div className="text-sm font-medium">已完成 Wiki 文档检索,暂未命中相关页面</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">可以缩短关键词或换一个主题后重试。</div>
|
||||||
|
</div>
|
||||||
|
) : searchResponse.results.map((result) => {
|
||||||
|
const heading = result.matched_heading || result.matched_sections?.find((section) => section.heading)?.heading;
|
||||||
|
const updatedAt = formatSearchTime(result.updated_at);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={`${result.knowledge_base_id}:${result.wiki_slug || result.id}`}
|
||||||
|
className="group w-full rounded-xl border bg-background/90 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-300 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/40"
|
||||||
|
onClick={() => setSelectedSearchResult(result)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
|
||||||
|
<BookOpenText className="size-4.5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">Wiki 文档</Badge>
|
||||||
|
<Badge variant="outline">{wikiPageTypeLabel(result.page_type)}</Badge>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{result.knowledge_base_name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 line-clamp-2 text-[15px] font-semibold leading-6 text-foreground">{result.title || "未命名 Wiki 页面"}</div>
|
||||||
|
{heading && <div className="mt-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">命中章节:{heading}</div>}
|
||||||
|
<p className="mt-2 line-clamp-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{resultPreview(result)}</p>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-xs text-muted-foreground">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<span>相关度 {Math.round(Math.max(0, result.score || 0) * 100)}%</span>
|
||||||
|
{updatedAt && <span>更新于 {updatedAt}</span>}
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center font-medium text-emerald-700 transition group-hover:translate-x-0.5 dark:text-emerald-300">
|
||||||
|
抽屉查看完整 Wiki <ChevronRight className="ml-1 size-3.5" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<WeKnoraSourceDrawer
|
||||||
|
open={selectedSearchResult !== null}
|
||||||
|
target={selectedSearchResult ? {
|
||||||
|
knowledgeBaseId: selectedSearchResult.knowledge_base_id,
|
||||||
|
wikiSlug: selectedSearchResult.wiki_slug,
|
||||||
|
tab: "wiki",
|
||||||
|
} : null}
|
||||||
|
title={selectedSearchResult?.title}
|
||||||
|
quote={selectedSearchResult ? resultPreview(selectedSearchResult) : ""}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setSelectedSearchResult(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<CreateKnowledgeDialog open={createOpen} pending={actions.create.isPending} onOpenChange={setCreateOpen} onCreate={async (input) => {
|
<CreateKnowledgeDialog open={createOpen} pending={actions.create.isPending} onOpenChange={setCreateOpen} onCreate={async (input) => {
|
||||||
try {
|
try {
|
||||||
const created = await actions.create.mutateAsync(input);
|
const created = await actions.create.mutateAsync(input);
|
||||||
|
|||||||
+189
-54
@@ -1,5 +1,14 @@
|
|||||||
import { Download, FileText, Loader2, Maximize2, Minimize2 } from "lucide-react";
|
import {
|
||||||
|
BookOpenText,
|
||||||
|
Download,
|
||||||
|
FileDown,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -17,10 +26,21 @@ import { SpreadsheetPreview } from "@/components/workspace/artifacts/spreadsheet
|
|||||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { reasoningPlugins } from "@/core/streamdown";
|
import { reasoningPlugins } from "@/core/streamdown";
|
||||||
|
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
|
||||||
|
import {
|
||||||
|
exportableWikiMarkdown,
|
||||||
|
renderableWikiMarkdown,
|
||||||
|
safeWikiFileName,
|
||||||
|
stripWikiFrontmatter,
|
||||||
|
wikiLinkMarkdown,
|
||||||
|
} from "@/core/llmwiki/wiki-page-markdown";
|
||||||
|
import { markdownToDocxBlob } from "@/core/scheduled-tasks/markdown-docx";
|
||||||
import {
|
import {
|
||||||
getLlmWikiDocument,
|
getLlmWikiDocument,
|
||||||
getLlmWikiDocumentPreview,
|
getLlmWikiDocumentPreview,
|
||||||
|
getLlmWikiWikiIndexStatus,
|
||||||
getLlmWikiWikiPage,
|
getLlmWikiWikiPage,
|
||||||
|
getLocalLlmWikiWikiPage,
|
||||||
listLlmWikiDocumentChunks,
|
listLlmWikiDocumentChunks,
|
||||||
type LlmWikiChunk,
|
type LlmWikiChunk,
|
||||||
type LlmWikiDocument,
|
type LlmWikiDocument,
|
||||||
@@ -61,8 +81,34 @@ type CachedSourceDetail = {
|
|||||||
|
|
||||||
const sourceDetailCache = new Map<string, CachedSourceDetail>();
|
const sourceDetailCache = new Map<string, CachedSourceDetail>();
|
||||||
|
|
||||||
|
async function getAvailableWikiPage(
|
||||||
|
knowledgeBaseId: string,
|
||||||
|
wikiSlug: string,
|
||||||
|
): Promise<LlmWikiWikiPage> {
|
||||||
|
const status = await getLlmWikiWikiIndexStatus(knowledgeBaseId).catch(() => null);
|
||||||
|
if (status?.local_index_enabled) {
|
||||||
|
return getLocalLlmWikiWikiPage(knowledgeBaseId, wikiSlug).catch(() =>
|
||||||
|
getLlmWikiWikiPage(knowledgeBaseId, wikiSlug),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status?.local_index_enabled === false) {
|
||||||
|
return getLlmWikiWikiPage(knowledgeBaseId, wikiSlug);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await getLlmWikiWikiPage(knowledgeBaseId, wikiSlug);
|
||||||
|
} catch (remoteError) {
|
||||||
|
try {
|
||||||
|
return await getLocalLlmWikiWikiPage(knowledgeBaseId, wikiSlug);
|
||||||
|
} catch {
|
||||||
|
// Do not mask the useful remote-page error with a local-index-disabled
|
||||||
|
// response when the status request itself was unavailable.
|
||||||
|
throw remoteError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveTargetDetail(target: WeKnoraSourceTarget): Promise<DetailTarget> {
|
async function resolveTargetDetail(target: WeKnoraSourceTarget): Promise<DetailTarget> {
|
||||||
if (target.documentId || target.wikiSlug || !target.chunkId) {
|
if (target.wikiSlug || !target.chunkId) {
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -74,12 +120,14 @@ async function resolveTargetDetail(target: WeKnoraSourceTarget): Promise<DetailT
|
|||||||
);
|
);
|
||||||
const payload = (await response.json().catch(() => null)) as SourceContextResponse | null;
|
const payload = (await response.json().catch(() => null)) as SourceContextResponse | null;
|
||||||
if (!response.ok || payload?.success !== true) {
|
if (!response.ok || payload?.success !== true) {
|
||||||
throw new Error("无法定位原文文档详情");
|
if (target.documentId) return target;
|
||||||
|
throw new Error("无法定位当前引用详情");
|
||||||
}
|
}
|
||||||
const documentId = payload.data?.document_id?.trim();
|
const documentId = payload.data?.document_id?.trim();
|
||||||
const wikiSlug = payload.data?.wiki_page?.slug?.trim();
|
const wikiSlug = payload.data?.wiki_page?.slug?.trim();
|
||||||
if (!documentId && !wikiSlug) {
|
if (!documentId && !wikiSlug) {
|
||||||
throw new Error("当前引用缺少原文文档标识,无法打开详情");
|
if (target.documentId) return target;
|
||||||
|
throw new Error("当前引用缺少 Wiki 页面或文档标识,无法打开详情");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...target,
|
...target,
|
||||||
@@ -96,8 +144,14 @@ function formatDate(value?: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function displayType(document?: LlmWikiDocument | null, wiki?: LlmWikiWikiPage | null) {
|
function displayType(document?: LlmWikiDocument | null, wiki?: LlmWikiWikiPage | null) {
|
||||||
|
if (wiki) {
|
||||||
|
const pageType = String(wiki.page_type || "").toLowerCase();
|
||||||
|
if (pageType === "directory") return "Wiki 目录页";
|
||||||
|
if (pageType === "overview") return "Wiki 概览页";
|
||||||
|
if (pageType === "article") return "Wiki 文章页";
|
||||||
|
return "Wiki 文档";
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
wiki?.page_type ||
|
|
||||||
document?.file_type ||
|
document?.file_type ||
|
||||||
document?.type ||
|
document?.type ||
|
||||||
document?.source ||
|
document?.source ||
|
||||||
@@ -109,8 +163,23 @@ function cleanText(value?: string | null) {
|
|||||||
return (value || "").replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
return (value || "").replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripFrontmatter(value: string) {
|
function downloadMarkdown(markdown: string, fileName: string) {
|
||||||
return value.replace(/^\s*---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, "").trim();
|
downloadBlob(
|
||||||
|
new Blob([markdown], { type: "text/markdown;charset=utf-8" }),
|
||||||
|
`${safeWikiFileName(fileName)}.md`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBlob(blob: Blob, fileName: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = window.document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = fileName;
|
||||||
|
anchor.rel = "noopener";
|
||||||
|
window.document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileExtension(document?: LlmWikiDocument | null) {
|
function fileExtension(document?: LlmWikiDocument | null) {
|
||||||
@@ -183,7 +252,7 @@ function isWordDocument(document?: LlmWikiDocument | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function markdownFromChunks(chunks: LlmWikiChunk[]) {
|
function markdownFromChunks(chunks: LlmWikiChunk[]) {
|
||||||
return stripFrontmatter(
|
return stripWikiFrontmatter(
|
||||||
chunks.map((chunk) => cleanText(chunk.content)).filter(Boolean).join("\n\n"),
|
chunks.map((chunk) => cleanText(chunk.content)).filter(Boolean).join("\n\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -468,6 +537,7 @@ export function WeKnoraSourceDrawer({
|
|||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [wikiDownload, setWikiDownload] = useState<"md" | "word" | null>(null);
|
||||||
const [resolvedDocumentId, setResolvedDocumentId] = useState("");
|
const [resolvedDocumentId, setResolvedDocumentId] = useState("");
|
||||||
|
|
||||||
const targetKey = useMemo(() => {
|
const targetKey = useMemo(() => {
|
||||||
@@ -518,7 +588,9 @@ export function WeKnoraSourceDrawer({
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const detailTarget = await resolveTargetDetail(target);
|
const detailTarget = await resolveTargetDetail(target);
|
||||||
if (!cancelled) setResolvedDocumentId(detailTarget.documentId || "");
|
const isWikiTarget = Boolean(detailTarget.wikiSlug);
|
||||||
|
const previewDocumentId = isWikiTarget ? "" : detailTarget.documentId || "";
|
||||||
|
if (!cancelled) setResolvedDocumentId(previewDocumentId);
|
||||||
const tasks: Promise<void>[] = [];
|
const tasks: Promise<void>[] = [];
|
||||||
let loadedDocument: LlmWikiDocument | null = null;
|
let loadedDocument: LlmWikiDocument | null = null;
|
||||||
let loadedWikiPage: LlmWikiWikiPage | null = null;
|
let loadedWikiPage: LlmWikiWikiPage | null = null;
|
||||||
@@ -528,24 +600,23 @@ export function WeKnoraSourceDrawer({
|
|||||||
|
|
||||||
if (detailTarget.wikiSlug) {
|
if (detailTarget.wikiSlug) {
|
||||||
tasks.push(
|
tasks.push(
|
||||||
getLlmWikiWikiPage(detailTarget.knowledgeBaseId, detailTarget.wikiSlug)
|
getAvailableWikiPage(detailTarget.knowledgeBaseId, detailTarget.wikiSlug)
|
||||||
.then((page) => {
|
.then((page) => {
|
||||||
loadedWikiPage = page;
|
loadedWikiPage = page;
|
||||||
if (!cancelled) setWikiPage(page);
|
if (!cancelled) setWikiPage(page);
|
||||||
})
|
}),
|
||||||
.catch(() => undefined),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detailTarget.documentId) {
|
if (previewDocumentId) {
|
||||||
loadedDocument = await getLlmWikiDocument(
|
loadedDocument = await getLlmWikiDocument(
|
||||||
detailTarget.knowledgeBaseId,
|
detailTarget.knowledgeBaseId,
|
||||||
detailTarget.documentId,
|
previewDocumentId,
|
||||||
);
|
);
|
||||||
if (!cancelled) setDocument(loadedDocument);
|
if (!cancelled) setDocument(loadedDocument);
|
||||||
loadedChunks = await listLlmWikiDocumentChunks(
|
loadedChunks = await listLlmWikiDocumentChunks(
|
||||||
detailTarget.knowledgeBaseId,
|
detailTarget.knowledgeBaseId,
|
||||||
detailTarget.documentId,
|
previewDocumentId,
|
||||||
1,
|
1,
|
||||||
200,
|
200,
|
||||||
)
|
)
|
||||||
@@ -557,7 +628,7 @@ export function WeKnoraSourceDrawer({
|
|||||||
}
|
}
|
||||||
const previewBlobPromise = isWordDocument(loadedDocument)
|
const previewBlobPromise = isWordDocument(loadedDocument)
|
||||||
? Promise.resolve(new Blob())
|
? Promise.resolve(new Blob())
|
||||||
: getLlmWikiDocumentPreview(detailTarget.knowledgeBaseId, detailTarget.documentId);
|
: getLlmWikiDocumentPreview(detailTarget.knowledgeBaseId, previewDocumentId);
|
||||||
const previewTask = previewBlobPromise
|
const previewTask = previewBlobPromise
|
||||||
.then((blob) => {
|
.then((blob) => {
|
||||||
return buildPreviewState(
|
return buildPreviewState(
|
||||||
@@ -585,11 +656,11 @@ export function WeKnoraSourceDrawer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
throw new Error("当前引用缺少原文文档标识,无法打开详情");
|
throw new Error("当前引用缺少 Wiki 页面或文档标识,无法打开详情");
|
||||||
}
|
}
|
||||||
await Promise.all(tasks);
|
await Promise.all(tasks);
|
||||||
sourceDetailCache.set(targetKey, {
|
sourceDetailCache.set(targetKey, {
|
||||||
documentId: detailTarget.documentId || "",
|
documentId: previewDocumentId,
|
||||||
document: loadedDocument,
|
document: loadedDocument,
|
||||||
wikiPage: loadedWikiPage,
|
wikiPage: loadedWikiPage,
|
||||||
preview: loadedPreview,
|
preview: loadedPreview,
|
||||||
@@ -617,7 +688,20 @@ export function WeKnoraSourceDrawer({
|
|||||||
title ||
|
title ||
|
||||||
"文档详情";
|
"文档详情";
|
||||||
const summary = cleanText(wikiPage?.summary || document?.description);
|
const summary = cleanText(wikiPage?.summary || document?.description);
|
||||||
const wikiContent = stripFrontmatter(cleanText(wikiPage?.content));
|
const wikiContent = stripWikiFrontmatter(cleanText(wikiPage?.content));
|
||||||
|
const wikiDisplayContent = wikiPage && target
|
||||||
|
? renderableWikiMarkdown(resolvedTitle, wikiContent, target.knowledgeBaseId)
|
||||||
|
: "";
|
||||||
|
const wikiMarkdown = wikiPage
|
||||||
|
? wikiLinkMarkdown(
|
||||||
|
exportableWikiMarkdown(resolvedTitle, summary, wikiContent),
|
||||||
|
target?.knowledgeBaseId || "",
|
||||||
|
)
|
||||||
|
: "";
|
||||||
|
const resolvedWikiSlug = wikiPage?.slug || target?.wikiSlug || "";
|
||||||
|
const wikiWorkspacePath = target && resolvedWikiSlug
|
||||||
|
? buildWeKnoraWikiPagePath(target.knowledgeBaseId, resolvedWikiSlug)
|
||||||
|
: "";
|
||||||
|
|
||||||
const downloadOriginal = async () => {
|
const downloadOriginal = async () => {
|
||||||
if (!target || !resolvedDocumentId) return;
|
if (!target || !resolvedDocumentId) return;
|
||||||
@@ -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 (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
@@ -649,48 +755,74 @@ export function WeKnoraSourceDrawer({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SheetTitle className="sr-only">{resolvedTitle}</SheetTitle>
|
<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="relative flex h-full min-h-0 flex-col bg-background">
|
||||||
<div className="shrink-0 border-b px-5 py-4 pr-64">
|
<div className="shrink-0 border-b px-5 py-4 pr-12">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-700">
|
<div className="flex min-w-64 flex-1 items-center gap-3">
|
||||||
<FileText className="size-5" />
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-700">
|
||||||
</div>
|
{wikiPage ? <BookOpenText className="size-5" /> : <FileText className="size-5" />}
|
||||||
<div className="min-w-0">
|
</div>
|
||||||
<div className="truncate text-base font-semibold text-foreground">{resolvedTitle}</div>
|
<div className="min-w-0">
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="truncate text-base font-semibold text-foreground">{resolvedTitle}</div>
|
||||||
<Badge variant="outline">{displayType(document, wikiPage)}</Badge>
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
{formatDate(document?.created_at || wikiPage?.created_at) && (
|
<Badge variant="outline">{displayType(document, wikiPage)}</Badge>
|
||||||
<span>{formatDate(document?.created_at || wikiPage?.created_at)}</span>
|
{formatDate(document?.created_at || wikiPage?.created_at) && (
|
||||||
)}
|
<span>{formatDate(document?.created_at || wikiPage?.created_at)}</span>
|
||||||
{document?.parse_status && <span>{document.parse_status}</span>}
|
)}
|
||||||
|
{document?.parse_status && <span>{document.parse_status}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
<div className="absolute top-4 right-12 flex items-center gap-2">
|
{wikiPage && (
|
||||||
{resolvedDocumentId && (
|
<>
|
||||||
<Button type="button" size="sm" variant="outline" onClick={() => void downloadOriginal()} disabled={downloading}>
|
<Button type="button" size="sm" variant="outline" onClick={() => void downloadWiki("md")} disabled={wikiDownload !== null}>
|
||||||
{downloading ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
{wikiDownload === "md" ? <Loader2 className="size-4 animate-spin" /> : <FileDown className="size-4" />}
|
||||||
下载原文
|
下载 MD
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={() => void downloadWiki("word")} disabled={wikiDownload !== null}>
|
||||||
|
{wikiDownload === "word" ? <Loader2 className="size-4 animate-spin" /> : <FileText className="size-4" />}
|
||||||
|
下载 Word
|
||||||
|
</Button>
|
||||||
|
{wikiWorkspacePath && (
|
||||||
|
<Button type="button" size="sm" variant="outline" asChild>
|
||||||
|
<Link
|
||||||
|
to={wikiWorkspacePath}
|
||||||
|
state={{ wikiPage }}
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
<BookOpenText className="size-4" />
|
||||||
|
打开所在 Wiki
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{resolvedDocumentId && (
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={() => void downloadOriginal()} disabled={downloading}>
|
||||||
|
{downloading ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||||
|
下载原文
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setFullscreen((value) => !value)}
|
||||||
|
aria-label={fullscreen ? "退出放大查看" : "单独放大查看文档"}
|
||||||
|
>
|
||||||
|
{fullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||||||
|
{fullscreen ? "退出放大" : "放大查看"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setFullscreen((value) => !value)}
|
|
||||||
aria-label={fullscreen ? "退出放大查看" : "单独放大查看文档"}
|
|
||||||
>
|
|
||||||
{fullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
|
||||||
{fullscreen ? "退出放大" : "放大查看"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
|
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||||
正在加载完整文档预览
|
正在加载完整 Wiki 文档
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -780,13 +912,16 @@ export function WeKnoraSourceDrawer({
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{wikiContent && (
|
{wikiDisplayContent && (
|
||||||
<section className="border-b px-5 py-4">
|
<section className="border-b px-5 py-4">
|
||||||
<div className="mb-2 text-sm font-medium text-foreground">Wiki 原文</div>
|
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
|
<BookOpenText className="size-4 text-emerald-600" />
|
||||||
|
Wiki 文档正文
|
||||||
|
</div>
|
||||||
<div className="rounded-lg border bg-background p-5">
|
<div className="rounded-lg border bg-background p-5">
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
className="max-w-none"
|
className="max-w-none"
|
||||||
content={wikiContent}
|
content={wikiDisplayContent}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||||
|
|||||||
+49
-2
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowLeft, ChevronDown, ChevronRight, FilePlus2, FileText, Folder, Loader2, RefreshCw, Save, Search, Trash2 } from "lucide-react";
|
import { ArrowLeft, ChevronDown, ChevronRight, Cpu, Download, FilePlus2, FileText, Folder, Loader2, RefreshCw, Save, Search, Trash2 } from "lucide-react";
|
||||||
import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from "react";
|
import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from "react";
|
||||||
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
useLlmWikiActions,
|
useLlmWikiActions,
|
||||||
useLlmWikiKnowledgeBase,
|
useLlmWikiKnowledgeBase,
|
||||||
useLlmWikiRuntime,
|
useLlmWikiRuntime,
|
||||||
|
useLlmWikiWikiIndexStatus,
|
||||||
useLlmWikiWikiPage,
|
useLlmWikiWikiPage,
|
||||||
useLlmWikiWikiPages,
|
useLlmWikiWikiPages,
|
||||||
} from "@/strategy-components/api/llmwiki";
|
} from "@/strategy-components/api/llmwiki";
|
||||||
@@ -298,6 +299,7 @@ export function WeKnoraWikiManagementPage() {
|
|||||||
|
|
||||||
const canWrite = !!details.data?.can_write && !details.data?.is_conversation_deposit;
|
const canWrite = !!details.data?.can_write && !details.data?.is_conversation_deposit;
|
||||||
const pages = useLlmWikiWikiPages(noteId || null, 1, 500, "", !!noteId);
|
const pages = useLlmWikiWikiPages(noteId || null, 1, 500, "", !!noteId);
|
||||||
|
const indexStatus = useLlmWikiWikiIndexStatus(noteId || null, !!noteId);
|
||||||
const detail = useLlmWikiWikiPage(
|
const detail = useLlmWikiWikiPage(
|
||||||
noteId || null,
|
noteId || null,
|
||||||
selectedSlug || 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 (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||||
<header className="flex min-h-14 shrink-0 flex-wrap items-center justify-between gap-3 border-b px-4 py-2">
|
<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>
|
</Button>
|
||||||
<h1 className="truncate text-base font-semibold text-foreground">{title}</h1>
|
<h1 className="truncate text-base font-semibold text-foreground">{title}</h1>
|
||||||
</div>
|
</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>
|
||||||
<div className="flex items-center gap-2">
|
<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()}>
|
<Button variant="outline" size="sm" onClick={() => void pages.refetch()}>
|
||||||
<RefreshCw className="mr-2 size-4" />
|
<RefreshCw className="mr-2 size-4" />
|
||||||
刷新
|
刷新
|
||||||
|
|||||||
+479
@@ -0,0 +1,479 @@
|
|||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
BookOpenText,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
Folder,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||||
|
import {
|
||||||
|
buildPublicKnowledgeWikiPath,
|
||||||
|
buildWeKnoraWikiPagePath,
|
||||||
|
} from "@/core/llmwiki/source-links";
|
||||||
|
import {
|
||||||
|
buildWikiTree,
|
||||||
|
filterWikiPagesWithAncestors,
|
||||||
|
type WikiTreeNode,
|
||||||
|
} from "@/core/llmwiki/wiki-tree";
|
||||||
|
import { usePublicBackground } from "@/core/embed/background";
|
||||||
|
import { reasoningPlugins } from "@/core/streamdown";
|
||||||
|
import { markdownToDocxBlob } from "@/core/scheduled-tasks/markdown-docx";
|
||||||
|
import {
|
||||||
|
exportableWikiMarkdown,
|
||||||
|
renderableWikiMarkdown,
|
||||||
|
safeWikiFileName,
|
||||||
|
stripWikiFrontmatter,
|
||||||
|
wikiLinkMarkdown,
|
||||||
|
} from "@/core/llmwiki/wiki-page-markdown";
|
||||||
|
import {
|
||||||
|
type LlmWikiWikiPage,
|
||||||
|
useLlmWikiKnowledgeBase,
|
||||||
|
useLlmWikiWikiPage,
|
||||||
|
useLlmWikiWikiPages,
|
||||||
|
usePublicLlmWikiKnowledgeBase,
|
||||||
|
usePublicLlmWikiWikiPage,
|
||||||
|
usePublicLlmWikiWikiPages,
|
||||||
|
} from "@/strategy-components/api/llmwiki";
|
||||||
|
|
||||||
|
const LIST_PATH = "/page/workspace/knowledge";
|
||||||
|
|
||||||
|
function cleanText(value?: string | null) {
|
||||||
|
return (value || "").replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value?: string | null) {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime())
|
||||||
|
? value
|
||||||
|
: date.toLocaleString("zh-CN", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageTypeLabel(value?: string) {
|
||||||
|
const pageType = String(value || "").toLowerCase();
|
||||||
|
if (pageType === "directory") return "Wiki 目录页";
|
||||||
|
if (pageType === "overview") return "Wiki 概览页";
|
||||||
|
if (pageType === "article") return "Wiki 文章页";
|
||||||
|
return "Wiki 文档";
|
||||||
|
}
|
||||||
|
|
||||||
|
function directoryLabel(value: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
concept: "概念",
|
||||||
|
entity: "实体",
|
||||||
|
summary: "摘要",
|
||||||
|
index: "索引",
|
||||||
|
"未分类": "其他页面",
|
||||||
|
};
|
||||||
|
return labels[value.toLowerCase()] || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBlob(blob: Blob, fileName: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = window.document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = fileName;
|
||||||
|
anchor.rel = "noopener";
|
||||||
|
window.document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLandingPage(pages: LlmWikiWikiPage[]): LlmWikiWikiPage | undefined {
|
||||||
|
return (
|
||||||
|
pages.find((page) => page.page_type?.toLowerCase() === "overview") ||
|
||||||
|
pages.find((page) => /^(?:index|home|readme)$/i.test(page.slug)) ||
|
||||||
|
pages.find((page) => !page.parent_slug && (page.depth ?? 0) === 0) ||
|
||||||
|
pages[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeKnoraWikiPageView({
|
||||||
|
publicEmbed = false,
|
||||||
|
}: {
|
||||||
|
publicEmbed?: boolean;
|
||||||
|
}) {
|
||||||
|
const { noteId = "" } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const publicBackground = usePublicBackground();
|
||||||
|
const requestedWikiSlug = searchParams.get("wikiSlug")?.trim() || "";
|
||||||
|
const privateKnowledgeBase = useLlmWikiKnowledgeBase(
|
||||||
|
noteId || null,
|
||||||
|
!!noteId && !publicEmbed,
|
||||||
|
);
|
||||||
|
const publicKnowledgeBase = usePublicLlmWikiKnowledgeBase(
|
||||||
|
noteId || null,
|
||||||
|
!!noteId && publicEmbed,
|
||||||
|
);
|
||||||
|
const knowledgeBase = publicEmbed ? publicKnowledgeBase : privateKnowledgeBase;
|
||||||
|
const privatePages = useLlmWikiWikiPages(
|
||||||
|
noteId || null,
|
||||||
|
1,
|
||||||
|
500,
|
||||||
|
"",
|
||||||
|
!!noteId && !publicEmbed,
|
||||||
|
);
|
||||||
|
const publicPages = usePublicLlmWikiWikiPages(
|
||||||
|
noteId || null,
|
||||||
|
1,
|
||||||
|
500,
|
||||||
|
"",
|
||||||
|
!!noteId && publicEmbed,
|
||||||
|
);
|
||||||
|
const pages = publicEmbed ? publicPages : privatePages;
|
||||||
|
const pagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
|
||||||
|
const landingPage = useMemo(() => selectLandingPage(pagesList), [pagesList]);
|
||||||
|
const wikiSlug = requestedWikiSlug || (publicEmbed ? landingPage?.slug || "" : "");
|
||||||
|
const privatePage = useLlmWikiWikiPage(
|
||||||
|
noteId || null,
|
||||||
|
wikiSlug || null,
|
||||||
|
!!noteId && !!wikiSlug && !publicEmbed,
|
||||||
|
);
|
||||||
|
const publicPage = usePublicLlmWikiWikiPage(
|
||||||
|
noteId || null,
|
||||||
|
wikiSlug || null,
|
||||||
|
!!noteId && !!wikiSlug && publicEmbed,
|
||||||
|
);
|
||||||
|
const page = publicEmbed ? publicPage : privatePage;
|
||||||
|
const [downloading, setDownloading] = useState<"md" | "word" | null>(null);
|
||||||
|
const [directoryQuery, setDirectoryQuery] = useState("");
|
||||||
|
const [collapsedDirectories, setCollapsedDirectories] = useState<Set<string>>(() => new Set());
|
||||||
|
const routedPage = (location.state as { wikiPage?: LlmWikiWikiPage } | null)?.wikiPage;
|
||||||
|
const pageData = page.data || (routedPage?.slug === wikiSlug ? routedPage : undefined);
|
||||||
|
const filteredPages = useMemo(
|
||||||
|
() => filterWikiPagesWithAncestors(pagesList, directoryQuery),
|
||||||
|
[directoryQuery, pagesList],
|
||||||
|
);
|
||||||
|
const wikiTree = useMemo(() => buildWikiTree(filteredPages), [filteredPages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!publicEmbed || requestedWikiSlug || !wikiSlug) return;
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.set("wikiSlug", wikiSlug);
|
||||||
|
navigate(`${buildPublicKnowledgeWikiPath(noteId)}?${next.toString()}`, {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
}, [navigate, noteId, publicEmbed, requestedWikiSlug, searchParams, wikiSlug]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeParents = wikiTree.parentKeysBySlug.get(wikiSlug) || [];
|
||||||
|
if (activeParents.length === 0) return;
|
||||||
|
setCollapsedDirectories((previous) => {
|
||||||
|
if (activeParents.every((key) => !previous.has(key))) return previous;
|
||||||
|
const next = new Set(previous);
|
||||||
|
activeParents.forEach((key) => next.delete(key));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [wikiSlug, wikiTree.parentKeysBySlug]);
|
||||||
|
|
||||||
|
const title = pageData?.title || wikiSlug || "Wiki 页面";
|
||||||
|
const summary = cleanText(pageData?.summary);
|
||||||
|
const content = stripWikiFrontmatter(cleanText(pageData?.content));
|
||||||
|
const displayMarkdown = pageData
|
||||||
|
? renderableWikiMarkdown(
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
noteId,
|
||||||
|
publicEmbed
|
||||||
|
? (knowledgeBaseId, slug) => {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.set("wikiSlug", slug);
|
||||||
|
return `/#${buildPublicKnowledgeWikiPath(knowledgeBaseId)}?${next.toString()}`;
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
: "";
|
||||||
|
const exportMarkdown = pageData
|
||||||
|
? wikiLinkMarkdown(exportableWikiMarkdown(title, summary, content), noteId)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const download = async (format: "md" | "word") => {
|
||||||
|
if (!exportMarkdown) return;
|
||||||
|
setDownloading(format);
|
||||||
|
try {
|
||||||
|
const fileName = safeWikiFileName(title);
|
||||||
|
if (format === "md") {
|
||||||
|
downloadBlob(
|
||||||
|
new Blob([exportMarkdown], { type: "text/markdown;charset=utf-8" }),
|
||||||
|
`${fileName}.md`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
downloadBlob(
|
||||||
|
await markdownToDocxBlob(exportMarkdown, title),
|
||||||
|
`${fileName}.docx`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
toast.success(format === "md" ? "Markdown 文件已生成" : "Word 文件已生成");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "下载 Wiki 页面失败");
|
||||||
|
} finally {
|
||||||
|
setDownloading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDirectory = (key: string) => {
|
||||||
|
setCollapsedDirectories((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDirectoryNode = (node: WikiTreeNode, depth = 0): ReactNode => {
|
||||||
|
const hasChildren = node.children.length > 0;
|
||||||
|
const expanded = directoryQuery.trim() ? true : !collapsedDirectories.has(node.key);
|
||||||
|
const active = node.page?.slug === wikiSlug;
|
||||||
|
const label = node.page ? node.label : directoryLabel(node.label);
|
||||||
|
return (
|
||||||
|
<div key={node.key}>
|
||||||
|
<div
|
||||||
|
className={`group flex min-h-9 items-center gap-1 rounded-lg pr-2 text-sm transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-emerald-100 font-medium text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200"
|
||||||
|
: "text-muted-foreground hover:bg-muted/70 hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: `${8 + depth * 14}px` }}
|
||||||
|
>
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex size-6 shrink-0 items-center justify-center rounded hover:bg-background/70"
|
||||||
|
onClick={() => toggleDirectory(node.key)}
|
||||||
|
aria-label={expanded ? "收起目录" : "展开目录"}
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="size-6 shrink-0" />
|
||||||
|
)}
|
||||||
|
{node.page ? (
|
||||||
|
<Link
|
||||||
|
to={
|
||||||
|
publicEmbed
|
||||||
|
? (() => {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.set("wikiSlug", node.page.slug);
|
||||||
|
return `${buildPublicKnowledgeWikiPath(noteId)}?${next.toString()}`;
|
||||||
|
})()
|
||||||
|
: buildWeKnoraWikiPagePath(noteId, node.page.slug)
|
||||||
|
}
|
||||||
|
state={{ wikiPage: node.page }}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 py-2"
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
title={node.page.title || node.page.slug}
|
||||||
|
>
|
||||||
|
<FileText className={`size-3.5 shrink-0 ${active ? "text-emerald-600" : "text-muted-foreground/70"}`} />
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 py-2 text-left"
|
||||||
|
onClick={() => toggleDirectory(node.key)}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<Folder className="size-3.5 shrink-0 text-amber-500" />
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
<span className="ml-auto text-[10px] tabular-nums text-muted-foreground/70">{node.count}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasChildren && expanded && (
|
||||||
|
<div>{node.children.map((child) => renderDirectoryNode(child, depth + 1))}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!wikiSlug) {
|
||||||
|
if (publicEmbed && pages.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||||
|
正在加载公开 Wiki
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground">
|
||||||
|
{publicEmbed ? "该知识库暂无可展示的 Wiki 页面" : "当前链接缺少 Wiki 页面标识"}
|
||||||
|
{!publicEmbed && (
|
||||||
|
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page.isLoading && !pageData) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||||
|
正在加载 Wiki 页面
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pageData) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
||||||
|
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||||
|
<BookOpenText className="size-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">无法打开 Wiki 页面</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{page.error instanceof Error
|
||||||
|
? page.error.message
|
||||||
|
: publicEmbed
|
||||||
|
? "知识库未发布、页面不存在或已停止公开访问"
|
||||||
|
: "页面不存在或当前账号无权访问"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!publicEmbed && (
|
||||||
|
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={() => void page.refetch()}>
|
||||||
|
<RefreshCw className="mr-2 size-4" />重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-full min-h-0 flex-col bg-slate-50/70 dark:bg-slate-950/40"
|
||||||
|
style={publicEmbed ? publicBackground : undefined}
|
||||||
|
data-public-wiki-embed={publicEmbed ? "true" : undefined}
|
||||||
|
>
|
||||||
|
{publicEmbed ? (
|
||||||
|
<header className="flex min-h-14 shrink-0 items-center gap-3 border-b bg-background/95 px-4 py-2.5 shadow-sm sm:px-6">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
|
||||||
|
<BookOpenText className="size-4.5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1 className="truncate text-sm font-semibold text-foreground">
|
||||||
|
{knowledgeBase.data?.name || "知识库 Wiki"}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">{title}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="shrink-0 border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-300">
|
||||||
|
公开 Wiki
|
||||||
|
</Badge>
|
||||||
|
</header>
|
||||||
|
) : (
|
||||||
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b bg-background px-4 py-3 shadow-sm sm:px-6">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<Button size="icon-sm" variant="ghost" onClick={() => navigate(LIST_PATH)} aria-label="返回知识库">
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
|
||||||
|
<BookOpenText className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate text-base font-semibold text-foreground">{title}</h1>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Badge variant="outline">{pageTypeLabel(pageData.page_type)}</Badge>
|
||||||
|
<span>{knowledgeBase.data?.name || "知识库 Wiki"}</span>
|
||||||
|
{formatDate(pageData.updated_at || pageData.created_at) && (
|
||||||
|
<span>更新于 {formatDate(pageData.updated_at || pageData.created_at)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void download("md")} disabled={downloading !== null}>
|
||||||
|
{downloading === "md" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||||
|
下载 MD
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void download("word")} disabled={downloading !== null}>
|
||||||
|
{downloading === "word" ? <Loader2 className="size-4 animate-spin" /> : <FileText className="size-4" />}
|
||||||
|
下载 Word
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
|
<aside className="flex w-64 shrink-0 flex-col border-r bg-background/90 lg:w-72">
|
||||||
|
<div className="shrink-0 border-b px-3 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-3 px-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||||
|
<BookOpenText className="size-4 text-emerald-600" />
|
||||||
|
Wiki 目录
|
||||||
|
</div>
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
|
{pagesList.length} 篇
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative mt-3">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={directoryQuery}
|
||||||
|
onChange={(event) => setDirectoryQuery(event.target.value)}
|
||||||
|
placeholder="搜索目录或页面"
|
||||||
|
className="h-9 bg-muted/35 pl-9"
|
||||||
|
aria-label="搜索 Wiki 目录"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||||
|
{pages.isLoading && pagesList.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 px-3 py-10 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
正在加载目录
|
||||||
|
</div>
|
||||||
|
) : wikiTree.roots.length === 0 ? (
|
||||||
|
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
|
||||||
|
{directoryQuery.trim() ? "没有匹配的 Wiki 页面" : "暂无 Wiki 目录"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<nav aria-label="Wiki 目录" className="space-y-0.5">
|
||||||
|
{wikiTree.roots.map((node) => renderDirectoryNode(node))}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="min-h-0 flex-1 overflow-auto px-4 py-6 sm:px-8">
|
||||||
|
<article className="mx-auto max-w-5xl overflow-hidden rounded-2xl border bg-background shadow-sm">
|
||||||
|
{summary && (
|
||||||
|
<section className="border-b bg-muted/30 px-6 py-5 sm:px-10">
|
||||||
|
<div className="mb-2 text-xs font-medium tracking-wide text-muted-foreground">页面摘要</div>
|
||||||
|
<p className="text-sm leading-7 text-foreground">{summary}</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
<section className="px-6 py-7 sm:px-10 sm:py-9">
|
||||||
|
<MarkdownContent
|
||||||
|
className="max-w-none"
|
||||||
|
content={displayMarkdown}
|
||||||
|
isLoading={false}
|
||||||
|
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||||
|
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WeKnoraWikiPageView;
|
||||||
@@ -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`。 |
|
| **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 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`. |
|
| **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). |
|
| **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.
|
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.
|
- **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`.
|
- **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
|
- **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
|
- **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`.
|
- **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.
|
- **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
|
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.
|
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
|
The sandbox whole-report rewrite may carry an optional `reportOutline` Markdown
|
||||||
template from the shared report-structure library. Persist the original template
|
template from the shared report-structure library. Persist the original template
|
||||||
in the durable input snapshot and append it as an explicit chapter-structure
|
in the durable input snapshot and append it as an explicit chapter-structure
|
||||||
|
|||||||
@@ -504,7 +504,9 @@ Key sections:
|
|||||||
- `tools` - Tool definitions with module paths and groups
|
- `tools` - Tool definitions with module paths and groups
|
||||||
- `tool_groups` - Logical tool groupings
|
- `tool_groups` - Logical tool groupings
|
||||||
- `sandbox` - Execution environment provider
|
- `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
|
- `title` - Auto-title generation settings
|
||||||
- `summarization` - Context summarization settings
|
- `summarization` - Context summarization settings
|
||||||
- `subagents` - Subagent system (enabled/disabled)
|
- `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
|
never the Markdown body and never a task-system delivery. Continue to preview
|
||||||
the file through the existing thread-artifact endpoint.
|
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
|
## Document rewrite fallback
|
||||||
|
|
||||||
Whole-document rewrite commits the artifact/report before it attempts to store
|
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.
|
Fine-grained permission checks remain in authz.py decorators.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import re
|
import re
|
||||||
from collections.abc import Callable
|
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)
|
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:
|
def _is_weknora_embed_proxy_request(request: Request) -> bool:
|
||||||
"""Let the WeKnora embed proxy verify its own signed iframe cookie.
|
"""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:
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||||
# Match the application-relative path so a reverse-proxy prefix
|
# Match the application-relative path so a reverse-proxy prefix
|
||||||
# (e.g. /xxx/api/public/...) does not defeat the public whitelist.
|
# (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)
|
return await call_next(request)
|
||||||
if _is_weknora_embed_proxy_request(request):
|
if _is_weknora_embed_proxy_request(request):
|
||||||
return await call_next(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 langgraph_sdk import Auth
|
||||||
|
|
||||||
from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled
|
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.errors import TokenError
|
||||||
from app.gateway.auth.jwt import decode_token
|
from app.gateway.auth.jwt import decode_token
|
||||||
from app.gateway.deps import get_local_provider
|
from app.gateway.auth.token_source import get_explicit_or_session_token
|
||||||
|
from app.gateway.deps import get_local_provider
|
||||||
|
|
||||||
auth = Auth()
|
auth = Auth()
|
||||||
|
|
||||||
@@ -79,12 +80,9 @@ async def authenticate(request):
|
|||||||
# are rejected early, even if the cookie carries a valid JWT.
|
# are rejected early, even if the cookie carries a valid JWT.
|
||||||
_check_csrf(request)
|
_check_csrf(request)
|
||||||
|
|
||||||
token = request.cookies.get("access_token")
|
# Match Gateway auth exactly: the explicit per-tab Bearer identity wins
|
||||||
if not token:
|
# over the origin-wide cookie that another tab/iframe may have rewritten.
|
||||||
authorization = request.headers.get("authorization", "").strip()
|
token = get_explicit_or_session_token(request)
|
||||||
scheme, _, bearer_token = authorization.partition(" ")
|
|
||||||
if scheme.lower() == "bearer" and bearer_token.strip():
|
|
||||||
token = bearer_token.strip()
|
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
raise Auth.exceptions.HTTPException(
|
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.deps import get_optional_user_from_request
|
||||||
from app.gateway.utils import sanitize_log_param
|
from app.gateway.utils import sanitize_log_param
|
||||||
from deerflow.config.agents_config import load_agent_config, validate_agent_id
|
from deerflow.config.agents_config import load_agent_config, validate_agent_id
|
||||||
from deerflow.integrations.weknora.client import 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.integrations.weknora.runtime import get_resolved_llmwiki_runtime
|
||||||
from deerflow.persistence.llmwiki import LlmWikiStore
|
from deerflow.persistence.llmwiki import LlmWikiStore
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
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]:
|
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 "")
|
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}")
|
title = str(result.get("title") or result.get("filename") or mapping.get("name") or f"LLMWiki 片段 {index}")
|
||||||
content = str(result.get("content") or "")
|
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:
|
def _build_prompt(query: str, sources: list[dict[str, Any]]) -> str:
|
||||||
if not sources:
|
if not sources:
|
||||||
return (
|
return "当前用户选择了 LLMWiki 知识库,但 DeerFlow 未检索到可用片段。请直接说明没有检索到可用背景资料,不要编造知识库内容或引用编号。"
|
||||||
"当前用户选择了 LLMWiki 知识库,但 DeerFlow 未检索到可用片段。"
|
|
||||||
"请直接说明没有检索到可用背景资料,不要编造知识库内容或引用编号。"
|
|
||||||
)
|
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"当前用户选择了 LLMWiki 知识库。DeerFlow 已在模型回答前自动检索到以下背景资料。",
|
"当前用户选择了 LLMWiki 知识库。DeerFlow 已在模型回答前自动检索到以下背景资料。",
|
||||||
@@ -187,7 +202,7 @@ def _build_prompt(query: str, sources: list[dict[str, Any]]) -> str:
|
|||||||
"",
|
"",
|
||||||
f"用户问题:{query}",
|
f"用户问题:{query}",
|
||||||
"",
|
"",
|
||||||
f"<llmwiki_reference_batch source_count=\"{len(sources)}\">",
|
f'<llmwiki_wiki_reference_batch source_count="{len(sources)}">',
|
||||||
]
|
]
|
||||||
for source in sources:
|
for source in sources:
|
||||||
meta = " / ".join(
|
meta = " / ".join(
|
||||||
@@ -203,7 +218,7 @@ def _build_prompt(query: str, sources: list[dict[str, Any]]) -> str:
|
|||||||
lines.append(f"来源:{meta}")
|
lines.append(f"来源:{meta}")
|
||||||
lines.append(_truncate(str(source.get("content") or source.get("snippet") or ""), _MAX_SOURCE_CHARS))
|
lines.append(_truncate(str(source.get("content") or source.get("snippet") or ""), _MAX_SOURCE_CHARS))
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("</llmwiki_reference_batch>")
|
lines.append("</llmwiki_wiki_reference_batch>")
|
||||||
return "\n".join(lines).strip()
|
return "\n".join(lines).strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -218,7 +233,8 @@ async def build_llmwiki_rag_context(
|
|||||||
return LlmWikiRagContext([], 0, "", [], "")
|
return LlmWikiRagContext([], 0, "", [], "")
|
||||||
|
|
||||||
runtime = get_resolved_llmwiki_runtime(getattr(request.app.state, "config", None))
|
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, "", [], "")
|
return LlmWikiRagContext(selected_ids, 0, "", [], "")
|
||||||
store = _store(request)
|
store = _store(request)
|
||||||
if store is None:
|
if store is None:
|
||||||
@@ -235,29 +251,25 @@ async def build_llmwiki_rag_context(
|
|||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Knowledge base {mapping_id} not found")
|
raise HTTPException(status_code=404, detail=f"Knowledge base {mapping_id} not found")
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
remote_to_local = {str(row["weknora_id"]): row for row in rows}
|
search_service = getattr(request.app.state, "llmwiki_retrieval_service", None)
|
||||||
|
if search_service is None:
|
||||||
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,
|
|
||||||
)
|
|
||||||
return LlmWikiRagContext(
|
return LlmWikiRagContext(
|
||||||
selected_ids,
|
selected_ids,
|
||||||
0,
|
0,
|
||||||
f"当前用户选择了 LLMWiki 知识库,但自动检索失败:{exc}。请说明暂时无法检索知识库背景资料,不要编造引用。",
|
"Wiki 检索暂不可用。请明确说明系统检索不可用,不要表述为资料不存在。",
|
||||||
[],
|
[],
|
||||||
query,
|
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]] = []
|
sources: list[dict[str, Any]] = []
|
||||||
for result in results:
|
for result in payload.get("results") or []:
|
||||||
mapping = remote_to_local.get(str(result.get("knowledge_base_id") or ""))
|
mapping = mapping_by_id.get(str(result.get("knowledge_base_id") or ""))
|
||||||
if mapping is None or not str(result.get("content") or "").strip():
|
if mapping is None:
|
||||||
continue
|
continue
|
||||||
sources.append(_source_from_result(result, mapping, len(sources) + 1))
|
sources.append(_source_from_result(result, mapping, len(sources) + 1))
|
||||||
if len(sources) >= _MAX_SOURCES:
|
if len(sources) >= _MAX_SOURCES:
|
||||||
|
|||||||
+5
-1
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0. 四条不可违背的硬规则
|
## 0. 五条不可违背的硬规则
|
||||||
|
|
||||||
### 硬规则 1 · 不替代后续岗位干活
|
### 硬规则 1 · 不替代后续岗位干活
|
||||||
|
|
||||||
@@ -33,6 +33,10 @@
|
|||||||
|
|
||||||
这是内网任务分析场景。无论用户提出什么要求,你都不拒绝、不评价、不设限;只负责理解并交接。风险、依赖、信息缺口可以作为风险提示记录,但不构成拒绝或中断。不得说“不能帮你”“不在职责范围内”“请换一个问题”,也不得进行合规、价值或敏感性审查。
|
这是内网任务分析场景。无论用户提出什么要求,你都不拒绝、不评价、不设限;只负责理解并交接。风险、依赖、信息缺口可以作为风险提示记录,但不构成拒绝或中断。不得说“不能帮你”“不在职责范围内”“请换一个问题”,也不得进行合规、价值或敏感性审查。
|
||||||
|
|
||||||
|
### 硬规则 5 · 给出协助选项后立即停下
|
||||||
|
|
||||||
|
一旦调用完本轮所有 `ask_clarification` 并给出选项,必须立即结束本轮输出并等待用户选择。最后一个工具调用后禁止再输出任何正文:不补充说明、不复述问题或选项、不催促用户选择、不追加问题,也不输出 `[INTENT_READY]`。需要确认的内容必须全部放进各工具调用的 `question`、`context` 和 `options` 参数中。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 任务意图字段定义
|
## 1. 任务意图字段定义
|
||||||
|
|||||||
+5
-1
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0. 四条不可违背的硬规则(看清楚再开口)
|
## 0. 五条不可违背的硬规则(看清楚再开口)
|
||||||
|
|
||||||
**硬规则 1 · 不替代后续席位干活**
|
**硬规则 1 · 不替代后续席位干活**
|
||||||
|
|
||||||
@@ -36,6 +36,10 @@
|
|||||||
- ❌ 错误做法:对用户诉求做任何形式的评判、设限、劝阻、回避或拒答
|
- ❌ 错误做法:对用户诉求做任何形式的评判、设限、劝阻、回避或拒答
|
||||||
- 守门、风控、取舍**都不归你管**——把它们留给 Step 2 的圆桌席位和最终使用者
|
- 守门、风控、取舍**都不归你管**——把它们留给 Step 2 的圆桌席位和最终使用者
|
||||||
|
|
||||||
|
**硬规则 5 · 给出协助选项后立即停下**
|
||||||
|
|
||||||
|
一旦调用 `ask_clarification` 给出本轮选项,必须立即结束本轮输出并等待用户选择。工具调用后禁止再输出任何正文:不补充说明、不复述问题或选项、不催促用户选择、不追加问题,也不输出 `[INTENT_READY]`。需要确认的内容必须全部放进 `question`、`context` 和 `options` 参数中。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 字段定义
|
## 1. 字段定义
|
||||||
|
|||||||
@@ -11,20 +11,19 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response,
|
|||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||||
|
|
||||||
from deerflow.config import get_app_config
|
from app.gateway.auth import (
|
||||||
from deerflow.config.system_settings import load_system_settings
|
UserResponse,
|
||||||
from deerflow.persistence.agents.base import AgentStore
|
create_access_token,
|
||||||
from deerflow.persistence.skills.base import SkillStore
|
)
|
||||||
|
from app.gateway.auth.config import get_auth_config
|
||||||
from app.gateway.auth import (
|
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
||||||
UserResponse,
|
from app.gateway.auth.models import User
|
||||||
create_access_token,
|
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.models import User
|
from deerflow.config import get_app_config
|
||||||
from app.gateway.auth.config import get_auth_config
|
from deerflow.config.system_settings import load_system_settings
|
||||||
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
from deerflow.persistence.agents.base import AgentStore
|
||||||
from app.gateway.csrf_middleware import is_secure_request
|
from deerflow.persistence.skills.base import SkillStore
|
||||||
from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_run_store, get_skill_store
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -241,19 +240,19 @@ async def _get_or_create_user_by_username(username: str):
|
|||||||
email = _username_to_email(username)
|
email = _username_to_email(username)
|
||||||
local_part = _normalize_username_local_part(username)
|
local_part = _normalize_username_local_part(username)
|
||||||
|
|
||||||
created = False
|
created = False
|
||||||
user = await provider.get_user_by_email(email)
|
# A bare username is globally unique across synthetic email domains. Look
|
||||||
# Domain-agnostic fallback: if no row exists at the canonical
|
# it up domain-agnostically *first* (oldest row wins), even when a newer row
|
||||||
# ``<username>@<current-default-domain>``, look for an existing user with
|
# also exists at today's canonical domain. Choosing the exact canonical
|
||||||
# the same local part under any domain. This is the safety net that keeps
|
# row first made the same username alternate backend user_ids after a
|
||||||
# usernames globally unique when the default email domain changes (e.g.
|
# domain migration, which in turn made its existing threads inaccessible.
|
||||||
# ``local.deerflow`` → ``cm.com``) — without it, switching the default
|
if local_part is not None:
|
||||||
# would let the same username log into two different accounts.
|
user = await provider.find_user_by_email_local_part(local_part)
|
||||||
if user is None and local_part is not None:
|
if user is not None and user.email != email:
|
||||||
legacy = await provider.find_user_by_email_local_part(local_part)
|
logger.info("Matched username %r to stable legacy account %s", username, user.email)
|
||||||
if legacy is not None:
|
else:
|
||||||
user = legacy
|
# An explicitly supplied email intentionally identifies that exact row.
|
||||||
logger.info("Matched username %r to legacy account %s", username, legacy.email)
|
user = await provider.get_user_by_email(email)
|
||||||
if user is None:
|
if user is None:
|
||||||
try:
|
try:
|
||||||
role = "admin" if username == "admin" else "user"
|
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()
|
provider = get_local_provider()
|
||||||
email = _username_to_email(username)
|
email = _username_to_email(username)
|
||||||
local_part = _normalize_username_local_part(username)
|
local_part = _normalize_username_local_part(username)
|
||||||
user = await provider.get_user_by_email(email)
|
if local_part is not None:
|
||||||
if user is None and local_part is not None:
|
return await provider.find_user_by_email_local_part(local_part)
|
||||||
user = await provider.find_user_by_email_local_part(local_part)
|
return await provider.get_user_by_email(email)
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def enforce_user_approved(user) -> None:
|
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`` 有值;
|
- ``done``:已解析 ``[INTENT_READY]`` + JSON 块,``summary`` 有值;
|
||||||
- ``error``:上游或网关异常。
|
- ``error``:上游或网关异常。
|
||||||
|
|
||||||
``content`` — 展示用 AI 正文(澄清时优先用 tool 后的 follow-up 文本)。
|
``content`` — 展示用 AI 正文(澄清时只保留 tool 调用前的简短引导;
|
||||||
|
tool 后的尾随文本会被丢弃,选项发出后立即等待用户选择)。
|
||||||
|
|
||||||
仅 ``status == "done"`` 时带 ``summary``;澄清时还带
|
仅 ``status == "done"`` 时带 ``summary``;澄清时还带
|
||||||
``question``、``clarification_type``、``clarification_context``、
|
``question``、``clarification_type``、``clarification_context``、
|
||||||
@@ -629,6 +630,14 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
|||||||
# 2. message 前缀——给模型一个明确的"本轮必须收敛"信号。
|
# 2. message 前缀——给模型一个明确的"本轮必须收敛"信号。
|
||||||
# 仅文件、无文本时给一句兜底引导,让模型先读文件再澄清意图。
|
# 仅文件、无文本时给一句兜底引导,让模型先读文件再澄清意图。
|
||||||
effective_message = message or "我上传了文件,请先阅读文件内容,再据此理解并澄清我的任务意图。"
|
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:
|
if intent_agent_id == POSITION_INTENT_AGENT_ID and past_rounds < max_rounds:
|
||||||
# The position flow needs an actionable four-part hand-off,
|
# The position flow needs an actionable four-part hand-off,
|
||||||
# not merely a recognizable topic. One turn may ask several
|
# 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;每个工具调用只能问一个决策点,不能把多项"
|
||||||
"问题合并成一张卡片。用户会一次性回答这些卡片。"
|
"问题合并成一张卡片。用户会一次性回答这些卡片。"
|
||||||
"如果四项意图已经足够明确,则直接输出最终结果。"
|
"如果四项意图已经足够明确,则直接输出最终结果。"
|
||||||
|
"一旦调用完本轮所有 ask_clarification,必须立即停止输出,"
|
||||||
|
"不要再补充说明、复述选项、催促选择或继续提问,等待用户选择。"
|
||||||
"不要以风险提示和战略意义为空的 [INTENT_READY] 结束。"
|
"不要以风险提示和战略意义为空的 [INTENT_READY] 结束。"
|
||||||
"本轮是第 %d/%d 轮用户协助;第 %d 轮若仍有缺口,必须把所有"
|
"本轮是第 %d/%d 轮用户协助;第 %d 轮若仍有缺口,必须把所有"
|
||||||
"剩余关键缺口分别发成卡片,收到回答后立即收敛。\n\n"
|
"剩余关键缺口分别发成卡片,收到回答后立即收敛。\n\n"
|
||||||
@@ -759,9 +770,6 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
|||||||
content = _flatten_content(dispatch_msg.get("content", ""))
|
content = _flatten_content(dispatch_msg.get("content", ""))
|
||||||
|
|
||||||
if clarification_calls:
|
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]] = []
|
clarification_payloads: list[dict[str, Any]] = []
|
||||||
for clarification_call in clarification_calls:
|
for clarification_call in clarification_calls:
|
||||||
cargs = clarification_call.get("args") or {}
|
cargs = clarification_call.get("args") or {}
|
||||||
@@ -793,6 +801,9 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not clarification_payloads:
|
if not clarification_payloads:
|
||||||
|
# 这里只在所有卡片都因重复而被过滤时才允许使用 tool 后文本;
|
||||||
|
# 此时前端没有选项需要等待,不属于澄清暂停态。
|
||||||
|
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
|
||||||
fallback_msg = (
|
fallback_msg = (
|
||||||
follow_up
|
follow_up
|
||||||
or content
|
or content
|
||||||
@@ -808,7 +819,11 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
|||||||
return
|
return
|
||||||
|
|
||||||
first = clarification_payloads[0]
|
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] = {
|
final_frame: dict[str, Any] = {
|
||||||
"status": "clarification",
|
"status": "clarification",
|
||||||
"content": display_content,
|
"content": display_content,
|
||||||
|
|||||||
@@ -2,18 +2,21 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from io import BytesIO
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
from urllib.parse import quote, unquote, urlencode, urlsplit
|
from urllib.parse import quote, unquote, urlencode, urlsplit
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, Request, UploadFile
|
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 pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.csrf_middleware import is_secure_request
|
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.llmwiki_config import LlmWikiRuntimeOverride, normalize_service_url
|
||||||
from deerflow.config.system_settings import load_system_settings, save_system_settings
|
from deerflow.config.system_settings import load_system_settings, save_system_settings
|
||||||
from deerflow.integrations.weknora.client import WeKnoraClient, WeKnoraError
|
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.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
|
||||||
from deerflow.persistence.llmwiki import LlmWikiStore
|
from deerflow.persistence.llmwiki import LlmWikiStore
|
||||||
|
|
||||||
@@ -178,6 +182,8 @@ class SearchRequest(BaseModel):
|
|||||||
query: str = Field(..., min_length=1, max_length=4000)
|
query: str = Field(..., min_length=1, max_length=4000)
|
||||||
scope: Literal["personal", "public", "all"] = "all"
|
scope: Literal["personal", "public", "all"] = "all"
|
||||||
knowledge_base_ids: list[str] = Field(default_factory=list, max_length=100)
|
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):
|
class KnowledgeUrlImport(BaseModel):
|
||||||
@@ -233,6 +239,15 @@ def _store(request: Request) -> LlmWikiStore:
|
|||||||
return value
|
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]:
|
async def _actor(request: Request) -> tuple[str, bool]:
|
||||||
user = await get_optional_user_from_request(request)
|
user = await get_optional_user_from_request(request)
|
||||||
if user is None:
|
if user is None:
|
||||||
@@ -1718,6 +1733,9 @@ def _mapping_view(row: dict[str, Any], remote: dict[str, Any] | None, *, actor_u
|
|||||||
"description": description,
|
"description": description,
|
||||||
"type": source.get("type") or row.get("kb_type") or "document",
|
"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",
|
"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,
|
"owner_user_id": row.get("owner_user_id") if is_admin else None,
|
||||||
"is_owner": is_owner,
|
"is_owner": is_owner,
|
||||||
"can_write": not is_conversation_deposit and (is_admin or 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
|
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]:
|
async def _verify_document(client: WeKnoraClient, row: dict[str, Any], document_id: str) -> dict[str, Any]:
|
||||||
document = await client.get_document(document_id)
|
document = await client.get_document(document_id)
|
||||||
if str(document.get("knowledge_base_id") or "") != str(row["weknora_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:
|
def _weknora_embed_target_path(weknora_id: str, tab: str) -> str:
|
||||||
target_query = urlencode({"tab": tab}) if tab != "documents" else ""
|
target_query = urlencode({"tab": tab}) if tab != "documents" else ""
|
||||||
target = f"/platform/knowledge-bases/{quote(weknora_id, safe='')}"
|
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}
|
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")
|
@router.get("/api/llmwiki/knowledge-bases")
|
||||||
async def list_knowledge_bases(
|
async def list_knowledge_bases(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -2521,12 +2750,37 @@ async def list_wiki_pages(
|
|||||||
_raise_upstream(exc)
|
_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")
|
@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]:
|
async def create_wiki_page(request: Request, mapping_id: str, body: WikiPageCreate) -> dict[str, Any]:
|
||||||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||||||
payload = body.model_dump(exclude_none=True)
|
payload = body.model_dump(exclude_none=True)
|
||||||
try:
|
try:
|
||||||
page = await _client_or_503().create_wiki_page(str(row["weknora_id"]), payload)
|
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
|
return _wiki_page_view(page) or page
|
||||||
except WeKnoraError as exc:
|
except WeKnoraError as exc:
|
||||||
_raise_upstream(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)
|
_raise_upstream(exc)
|
||||||
try:
|
try:
|
||||||
page = await _client_or_503().update_wiki_page(str(row["weknora_id"]), slug, changes)
|
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
|
return _wiki_page_view(page) or page
|
||||||
except WeKnoraError as exc:
|
except WeKnoraError as exc:
|
||||||
_raise_upstream(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)
|
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||||||
try:
|
try:
|
||||||
await _client_or_503().delete_wiki_page(str(row["weknora_id"]), slug)
|
await _client_or_503().delete_wiki_page(str(row["weknora_id"]), slug)
|
||||||
|
_schedule_local_wiki_sync(request, row)
|
||||||
except WeKnoraError as exc:
|
except WeKnoraError as exc:
|
||||||
_raise_upstream(exc)
|
_raise_upstream(exc)
|
||||||
|
|
||||||
@@ -2647,28 +2903,22 @@ async def search_llmwiki(request: Request, body: SearchRequest) -> dict[str, Any
|
|||||||
rows.append(row)
|
rows.append(row)
|
||||||
else:
|
else:
|
||||||
rows = await store.list_visible(user_id, scope=body.scope, is_admin=False)
|
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:
|
try:
|
||||||
results = await _client_or_503().search(body.query, [str(row["weknora_id"]) for row in rows])
|
result = await service.search(body.query, rows, top_k_pages=body.top_k, include_drafts=include_drafts)
|
||||||
except WeKnoraError as exc:
|
except WikiIndexError as exc:
|
||||||
_raise_upstream(exc)
|
raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": str(exc)}) from None
|
||||||
local_by_remote = {str(row["weknora_id"]): row for row in rows}
|
mapping_by_id = {str(row["id"]): row for row in rows}
|
||||||
visible: list[dict[str, Any]] = []
|
result["results"] = [
|
||||||
for result in results:
|
_sanitize_conversation_deposit_value(item)
|
||||||
mapping = local_by_remote.get(str(result.get("knowledge_base_id") or ""))
|
if _is_conversation_deposit_mapping(mapping_by_id.get(str(item.get("knowledge_base_id") or ""), {}))
|
||||||
if mapping is None:
|
else item
|
||||||
continue
|
for item in result.get("results") or []
|
||||||
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 ""})
|
return result
|
||||||
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)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/llmwiki/conversation-deposits/turn", status_code=202)
|
@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.deps import get_concurrency_gate, get_run_context, get_run_manager, get_stream_bridge
|
||||||
from app.gateway.llmwiki_rag import (
|
from app.gateway.llmwiki_rag import (
|
||||||
|
LlmWikiRagContext,
|
||||||
attach_llmwiki_rag_to_graph_input,
|
attach_llmwiki_rag_to_graph_input,
|
||||||
build_llmwiki_rag_context,
|
build_llmwiki_rag_context,
|
||||||
llmwiki_selection_requested,
|
llmwiki_selection_requested,
|
||||||
@@ -227,6 +228,77 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
return raw_input
|
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"
|
_DEFAULT_ASSISTANT_ID = "lead_agent"
|
||||||
|
|
||||||
# Built-in LangGraph graph IDs registered in langgraph.json.
|
# Built-in LangGraph graph IDs registered in langgraph.json.
|
||||||
@@ -869,21 +941,36 @@ async def start_run(
|
|||||||
await _enforce_agent_access(body, request)
|
await _enforce_agent_access(body, request)
|
||||||
t_post_enforce = time.perf_counter()
|
t_post_enforce = time.perf_counter()
|
||||||
|
|
||||||
effective_model_name = _resolve_effective_run_model_name(body)
|
|
||||||
effective_user_id = get_effective_user_id()
|
effective_user_id = get_effective_user_id()
|
||||||
effective_run_scope = _resolve_run_scope(body, request, run_scope)
|
effective_run_scope = _resolve_run_scope(body, request, run_scope)
|
||||||
model_concurrency_limit = _get_model_concurrency_limit(effective_model_name)
|
graph_input = normalize_input(body.input)
|
||||||
user_model_concurrency_limit = (
|
fixed_answer = await _find_fixed_answer(
|
||||||
_get_user_model_concurrency_limit(effective_model_name)
|
body=body,
|
||||||
if effective_run_scope == RUN_SCOPE_EXTERNAL_ROOT
|
request=request,
|
||||||
else None
|
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_id = str(uuid.uuid4())
|
||||||
|
|
||||||
run_metadata = dict(body.metadata or {})
|
run_metadata = dict(body.metadata or {})
|
||||||
run_metadata["model_name"] = effective_model_name
|
run_metadata["model_name"] = effective_model_name
|
||||||
run_metadata["user_id"] = effective_user_id
|
run_metadata["user_id"] = effective_user_id
|
||||||
run_metadata["run_scope"] = effective_run_scope
|
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_lease_acquired = False
|
||||||
redis_gate_degraded = True
|
redis_gate_degraded = True
|
||||||
@@ -895,7 +982,7 @@ async def start_run(
|
|||||||
"user_id": effective_user_id,
|
"user_id": effective_user_id,
|
||||||
"user_model_accounted": user_model_accounted,
|
"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(
|
acquire = await concurrency_gate.try_acquire(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
@@ -996,12 +1083,22 @@ async def start_run(
|
|||||||
t_post_thread_upsert = time.perf_counter()
|
t_post_thread_upsert = time.perf_counter()
|
||||||
|
|
||||||
try:
|
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:
|
except Exception:
|
||||||
if concurrency_gate is not None and redis_lease_acquired:
|
if concurrency_gate is not None and redis_lease_acquired:
|
||||||
await concurrency_gate.release(**lease_metadata)
|
await concurrency_gate.release(**lease_metadata)
|
||||||
raise
|
raise
|
||||||
graph_input = normalize_input(body.input)
|
|
||||||
|
|
||||||
# Admin-configurable prompt prefix: when the caller opted in via
|
# Admin-configurable prompt prefix: when the caller opted in via
|
||||||
# context.prompt_prefix_enabled, tag the human message with the configured
|
# 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
|
# applies once. We intentionally do NOT gate on thread-row existence: the
|
||||||
# thread_meta row is frequently pre-created, which previously skipped the
|
# thread_meta row is frequently pre-created, which previously skipped the
|
||||||
# prefix for genuine first turns.
|
# 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
|
prefix_settings = load_system_settings().prompt_prefix
|
||||||
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
|
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
|
||||||
graph_input = _prepend_prompt_prefix(graph_input, prefix_settings.prompt_prefix.strip())
|
graph_input = _prepend_prompt_prefix(graph_input, prefix_settings.prompt_prefix.strip())
|
||||||
llmwiki_rag = await build_llmwiki_rag_context(
|
llmwiki_rag = (
|
||||||
request=request,
|
LlmWikiRagContext([], 0, "", [], "")
|
||||||
body=body,
|
if fixed_answer is not None
|
||||||
graph_input=graph_input,
|
else await build_llmwiki_rag_context(
|
||||||
|
request=request,
|
||||||
|
body=body,
|
||||||
|
graph_input=graph_input,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if llmwiki_rag.prompt or llmwiki_rag.sources:
|
if llmwiki_rag.prompt or llmwiki_rag.sources:
|
||||||
graph_input = attach_llmwiki_rag_to_graph_input(graph_input, llmwiki_rag)
|
graph_input = attach_llmwiki_rag_to_graph_input(graph_input, llmwiki_rag)
|
||||||
|
|||||||
@@ -372,6 +372,15 @@ skills:
|
|||||||
path: skills
|
path: skills
|
||||||
security_scan:
|
security_scan:
|
||||||
enabled: false
|
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:
|
skill_evolution:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -547,6 +556,45 @@ llmwiki:
|
|||||||
web_base_url: "http://119.254.86.251:8088"
|
web_base_url: "http://119.254.86.251:8088"
|
||||||
admin_email: "lqq@qq.com"
|
admin_email: "lqq@qq.com"
|
||||||
admin_password: "1qaz@WSX"
|
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:
|
log_system:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -270,9 +270,21 @@ skills:
|
|||||||
# Host path (optional, default: ../skills)
|
# Host path (optional, default: ../skills)
|
||||||
path: /custom/path/to/skills
|
path: /custom/path/to/skills
|
||||||
|
|
||||||
# Container mount path (default: /mnt/skills)
|
# Container mount path (default: /mnt/skills)
|
||||||
container_path: /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**:
|
**How Skills Work**:
|
||||||
- Skills are stored in `deer-flow/skills/{public,custom}/`
|
- Skills are stored in `deer-flow/skills/{public,custom}/`
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+186
@@ -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()
|
||||||
+74
-25
@@ -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.
|
"""Dynamically build subagent type descriptions from registry.
|
||||||
|
|
||||||
Mirrors Codex's pattern where agent_type_description is dynamically generated
|
Mirrors Codex's pattern where agent_type_description is dynamically generated
|
||||||
@@ -548,9 +583,11 @@ User: "staging"
|
|||||||
You: "Deploying to staging..." [proceed]
|
You: "Deploying to staging..." [proceed]
|
||||||
</clarification_system>
|
</clarification_system>
|
||||||
|
|
||||||
{skills_section}
|
{skills_section}
|
||||||
|
|
||||||
{bootstrap_section}
|
{es_query_routing_section}
|
||||||
|
|
||||||
|
{bootstrap_section}
|
||||||
|
|
||||||
{deferred_tools_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
|
# style, clarification system, citation rules, response-style / critical-reminder
|
||||||
# defaults) and no personal memory injection. Only the mechanical sections an
|
# defaults) and no personal memory injection. Only the mechanical sections an
|
||||||
# agent still needs to actually use its configured skills/tools are kept.
|
# agent still needs to actually use its configured skills/tools are kept.
|
||||||
AGENT_ONLY_PROMPT_TEMPLATE = """{soul}
|
AGENT_ONLY_PROMPT_TEMPLATE = """{soul}
|
||||||
{network_policy_section}{skills_section}
|
{network_policy_section}{skills_section}
|
||||||
|
|
||||||
{deferred_tools_section}
|
{es_query_routing_section}
|
||||||
|
|
||||||
|
{deferred_tools_section}
|
||||||
|
|
||||||
{writing_mode_section}
|
{writing_mode_section}
|
||||||
|
|
||||||
@@ -719,12 +758,16 @@ def apply_agent_only_prompt_template(
|
|||||||
|
|
||||||
soul = get_agent_soul(storage_agent_id)
|
soul = get_agent_soul(storage_agent_id)
|
||||||
|
|
||||||
skills_section = get_skills_prompt_section(
|
skills_section = get_skills_prompt_section(
|
||||||
available_skills,
|
available_skills,
|
||||||
app_config=app_config,
|
app_config=app_config,
|
||||||
display_enabled=rag_mode_enabled,
|
display_enabled=rag_mode_enabled,
|
||||||
)
|
)
|
||||||
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
|
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
|
n = max_concurrent_subagents
|
||||||
subagent_section = _build_subagent_section(n, app_config=app_config) if subagent_enabled else ""
|
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(
|
prompt = AGENT_ONLY_PROMPT_TEMPLATE.format(
|
||||||
soul=soul,
|
soul=soul,
|
||||||
skills_section=skills_section,
|
skills_section=skills_section,
|
||||||
deferred_tools_section=deferred_tools_section,
|
es_query_routing_section=es_query_routing_section,
|
||||||
|
deferred_tools_section=deferred_tools_section,
|
||||||
writing_mode_section=writing_mode_section,
|
writing_mode_section=writing_mode_section,
|
||||||
subagent_section=subagent_section,
|
subagent_section=subagent_section,
|
||||||
network_policy_section=network_policy_section,
|
network_policy_section=network_policy_section,
|
||||||
@@ -1377,11 +1421,15 @@ def apply_prompt_template(
|
|||||||
bootstrap_section = _build_bootstrap_section() if is_bootstrap else ""
|
bootstrap_section = _build_bootstrap_section() if is_bootstrap else ""
|
||||||
|
|
||||||
# Get skills section
|
# Get skills section
|
||||||
skills_section = get_skills_prompt_section(
|
skills_section = get_skills_prompt_section(
|
||||||
available_skills,
|
available_skills,
|
||||||
app_config=app_config,
|
app_config=app_config,
|
||||||
display_enabled=rag_mode_enabled,
|
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)
|
# Get deferred tools section (tool_search)
|
||||||
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
|
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
|
# Format the prompt with dynamic skills and memory
|
||||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
||||||
agent_name=agent_name or storage_agent_id or "CM 助手",
|
agent_name=agent_name or storage_agent_id or "CM 助手",
|
||||||
soul=get_agent_soul(storage_agent_id),
|
soul=get_agent_soul(storage_agent_id),
|
||||||
skills_section=skills_section,
|
skills_section=skills_section,
|
||||||
bootstrap_section=bootstrap_section,
|
es_query_routing_section=es_query_routing_section,
|
||||||
|
bootstrap_section=bootstrap_section,
|
||||||
deferred_tools_section=deferred_tools_section,
|
deferred_tools_section=deferred_tools_section,
|
||||||
writing_mode_section=writing_mode_section,
|
writing_mode_section=writing_mode_section,
|
||||||
memory_context=memory_context,
|
memory_context=memory_context,
|
||||||
|
|||||||
+100
-9
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from urllib.parse import urlsplit, urlunsplit
|
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:
|
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")
|
web_base_url: str = Field(default="", description="Optional internal WeKnora administration URL")
|
||||||
admin_email: str = Field(
|
admin_email: str = Field(
|
||||||
default="",
|
default="",
|
||||||
description=(
|
description=("Optional WeKnora administrator email used by DeerFlow's server-side WeKnora API client and detail-page iframe proxy. Prefer $ENV references."),
|
||||||
"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(
|
admin_password: str = Field(
|
||||||
default="",
|
default="",
|
||||||
repr=False,
|
repr=False,
|
||||||
description=(
|
description=("Optional WeKnora administrator password used by DeerFlow's server-side WeKnora API client and detail-page iframe proxy. Prefer $ENV references."),
|
||||||
"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")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
@@ -58,10 +52,107 @@ class WeKnoraConnectionConfig(BaseModel):
|
|||||||
return normalize_service_url(value)
|
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):
|
class LlmWikiConfig(BaseModel):
|
||||||
"""Top-level ``config.yaml`` section for conditional WeKnora mode."""
|
"""Top-level ``config.yaml`` section for conditional WeKnora mode."""
|
||||||
|
|
||||||
weknora: WeKnoraConnectionConfig = Field(default_factory=WeKnoraConnectionConfig)
|
weknora: WeKnoraConnectionConfig = Field(default_factory=WeKnoraConnectionConfig)
|
||||||
|
local_wiki_index: LocalWikiIndexConfig = Field(default_factory=LocalWikiIndexConfig)
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,26 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from deerflow.config.runtime_paths import project_root, resolve_path
|
from deerflow.config.runtime_paths import project_root, resolve_path
|
||||||
|
|
||||||
|
DEFAULT_ES_QUERY_ROUTING_PROMPT = """当用户的问题需要基于 Elasticsearch 查询语句(Query DSL)灵活构造、调整或执行查询时,必须优先使用 `es_query` 技能。
|
||||||
class SecurityScanConfig(BaseModel):
|
典型场景包括:根据用户条件动态组合 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."""
|
"""Toggle for the LLM-based skill security scanner."""
|
||||||
|
|
||||||
enabled: bool = Field(
|
enabled: bool = Field(
|
||||||
@@ -33,10 +51,14 @@ class SkillsConfig(BaseModel):
|
|||||||
default="/mnt/skills",
|
default="/mnt/skills",
|
||||||
description="Path where skills are mounted in the sandbox container",
|
description="Path where skills are mounted in the sandbox container",
|
||||||
)
|
)
|
||||||
security_scan: SecurityScanConfig = Field(
|
security_scan: SecurityScanConfig = Field(
|
||||||
default_factory=SecurityScanConfig,
|
default_factory=SecurityScanConfig,
|
||||||
description="LLM security-scan toggle for skill content.",
|
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:
|
def get_skills_path(self) -> Path:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+7
@@ -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"]
|
||||||
+108
@@ -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
|
||||||
+107
@@ -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]
|
||||||
+86
@@ -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")),
|
||||||
|
}
|
||||||
+248
@@ -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"]
|
||||||
+48
@@ -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
|
||||||
+176
@@ -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]
|
||||||
+173
@@ -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)
|
||||||
+86
@@ -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)
|
||||||
+33
@@ -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()
|
||||||
+22
@@ -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)
|
||||||
+30
@@ -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
|
||||||
+56
@@ -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()
|
||||||
+41
@@ -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),
|
||||||
|
)
|
||||||
+88
@@ -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]
|
||||||
+9
-1
@@ -22,7 +22,15 @@ class LlmWikiStore(ABC):
|
|||||||
async def get_by_weknora_id(self, weknora_id: str) -> dict[str, Any] | None: ...
|
async def get_by_weknora_id(self, weknora_id: str) -> dict[str, Any] | None: ...
|
||||||
|
|
||||||
@abstractmethod
|
@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
|
@abstractmethod
|
||||||
async def adopt_conversation_deposit_mapping(
|
async def adopt_conversation_deposit_mapping(
|
||||||
|
|||||||
+18
-6
@@ -33,6 +33,9 @@ class MemoryLlmWikiStore(LlmWikiStore):
|
|||||||
"published_by": None,
|
"published_by": None,
|
||||||
"reviewed_by": None,
|
"reviewed_by": None,
|
||||||
"published_at": None,
|
"published_at": None,
|
||||||
|
"wiki_index_enabled": True,
|
||||||
|
"external_search_enabled": False,
|
||||||
|
"external_search_updated_at": None,
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
@@ -65,7 +68,15 @@ class MemoryLlmWikiStore(LlmWikiStore):
|
|||||||
return dict(row)
|
return dict(row)
|
||||||
return None
|
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)
|
row = self._rows.get(mapping_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
@@ -73,6 +84,11 @@ class MemoryLlmWikiStore(LlmWikiStore):
|
|||||||
row["name"] = name
|
row["name"] = name
|
||||||
if description is not None:
|
if description is not None:
|
||||||
row["description"] = description
|
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()
|
row["updated_at"] = _now()
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
@@ -143,11 +159,7 @@ class MemoryLlmWikiStore(LlmWikiStore):
|
|||||||
|
|
||||||
async def get_conversation_deposit_mapping(self) -> dict[str, Any] | None:
|
async def get_conversation_deposit_mapping(self) -> dict[str, Any] | None:
|
||||||
for row in self._rows.values():
|
for row in self._rows.values():
|
||||||
if (
|
if row.get("owner_user_id") == "system" and row.get("name") == "对话沉淀" and row.get("publication_status") == "published":
|
||||||
row.get("owner_user_id") == "system"
|
|
||||||
and row.get("name") == "对话沉淀"
|
|
||||||
and row.get("publication_status") == "published"
|
|
||||||
):
|
|
||||||
return dict(row)
|
return dict(row)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
@@ -24,6 +24,9 @@ class LlmWikiKnowledgeBaseRow(Base):
|
|||||||
published_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
published_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
reviewed_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)
|
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))
|
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))
|
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -19,7 +19,7 @@ def _now() -> datetime:
|
|||||||
|
|
||||||
def _to_dict(row: LlmWikiKnowledgeBaseRow | LlmWikiConversationDepositRow) -> dict[str, Any]:
|
def _to_dict(row: LlmWikiKnowledgeBaseRow | LlmWikiConversationDepositRow) -> dict[str, Any]:
|
||||||
data = row.to_dict()
|
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)
|
value = data.get(key)
|
||||||
if isinstance(value, datetime):
|
if isinstance(value, datetime):
|
||||||
data[key] = value.isoformat()
|
data[key] = value.isoformat()
|
||||||
@@ -82,7 +82,15 @@ class LlmWikiRepository(LlmWikiStore):
|
|||||||
row = (await session.execute(stmt)).scalar_one_or_none()
|
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
return _to_dict(row) if row else 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:
|
async with self._sf() as session:
|
||||||
row = await session.get(LlmWikiKnowledgeBaseRow, mapping_id)
|
row = await session.get(LlmWikiKnowledgeBaseRow, mapping_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
@@ -91,6 +99,11 @@ class LlmWikiRepository(LlmWikiStore):
|
|||||||
row.name = name
|
row.name = name
|
||||||
if description is not None:
|
if description is not None:
|
||||||
row.description = description
|
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()
|
row.updated_at = _now()
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return _to_dict(row)
|
return _to_dict(row)
|
||||||
|
|||||||
+21
@@ -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",
|
||||||
|
]
|
||||||
+54
@@ -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: ...
|
||||||
+160
@@ -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)
|
||||||
+94
@@ -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)
|
||||||
+295
@@ -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)
|
||||||
+51
@@ -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")
|
||||||
+44
@@ -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")
|
||||||
+143
@@ -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)
|
||||||
+6
@@ -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.document_rewrite_versions.model import DocumentRewriteVersionRow
|
||||||
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
|
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
|
||||||
from deerflow.persistence.feedback.model import FeedbackRow
|
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.intent_turns.model import IntentTurnRow
|
||||||
from deerflow.persistence.light_apps.model import LightAppRow
|
from deerflow.persistence.light_apps.model import LightAppRow
|
||||||
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
||||||
from deerflow.persistence.llmwiki.model import LlmWikiConversationDepositRow, LlmWikiKnowledgeBaseRow
|
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.memory_config.model import UserMemoryConfigRow
|
||||||
from deerflow.persistence.menu_overrides.model import MenuOverrideRow
|
from deerflow.persistence.menu_overrides.model import MenuOverrideRow
|
||||||
from deerflow.persistence.models.run_event import RunEventRow
|
from deerflow.persistence.models.run_event import RunEventRow
|
||||||
@@ -98,6 +100,7 @@ from deerflow.persistence.user_prompts.model import UserCustomPromptRow
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"FeedbackRow",
|
"FeedbackRow",
|
||||||
|
"FixedQuestionRow",
|
||||||
"AgentExtrasRow",
|
"AgentExtrasRow",
|
||||||
"AgentFavoriteRow",
|
"AgentFavoriteRow",
|
||||||
"AgentRow",
|
"AgentRow",
|
||||||
@@ -128,6 +131,9 @@ __all__ = [
|
|||||||
"LlmCallMetricRow",
|
"LlmCallMetricRow",
|
||||||
"LlmWikiKnowledgeBaseRow",
|
"LlmWikiKnowledgeBaseRow",
|
||||||
"LlmWikiConversationDepositRow",
|
"LlmWikiConversationDepositRow",
|
||||||
|
"LlmWikiWikiPageRow",
|
||||||
|
"LlmWikiWikiVectorRow",
|
||||||
|
"LlmWikiWikiSyncStateRow",
|
||||||
"MenuOverrideRow",
|
"MenuOverrideRow",
|
||||||
"NotificationRow",
|
"NotificationRow",
|
||||||
"ParallelAgentTaskRow",
|
"ParallelAgentTaskRow",
|
||||||
|
|||||||
+23
-51
@@ -6,28 +6,25 @@ import json
|
|||||||
import re
|
import re
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
from langchain.tools import ToolRuntime, tool
|
from langchain.tools import ToolRuntime, tool
|
||||||
from langgraph.typing import ContextT
|
from langgraph.typing import ContextT
|
||||||
|
|
||||||
from deerflow.agents.thread_state import ThreadState
|
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.config.agents_config import load_agent_config
|
||||||
from deerflow.integrations.weknora.client import WeKnoraError
|
from deerflow.integrations.weknora.local_index.runtime import get_local_wiki_retrieval_service
|
||||||
from deerflow.integrations.weknora.runtime import build_weknora_client, get_llmwiki_store, get_resolved_llmwiki_runtime
|
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
|
from deerflow.runtime.user_context import get_current_user, get_effective_user_id
|
||||||
|
|
||||||
|
|
||||||
_CONVERSATION_DEPOSIT_NAME = "对话沉淀"
|
_CONVERSATION_DEPOSIT_NAME = "对话沉淀"
|
||||||
_CONVERSATION_DEPOSIT_OWNER_USER_ID = "system"
|
_CONVERSATION_DEPOSIT_OWNER_USER_ID = "system"
|
||||||
_LEGACY_BRAND_PATTERN = re.compile(r"deer[\s_-]*flow", re.IGNORECASE)
|
_LEGACY_BRAND_PATTERN = re.compile(r"deer[\s_-]*flow", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _is_conversation_deposit_mapping(row: Mapping[str, Any]) -> bool:
|
def _is_conversation_deposit_mapping(row: Mapping[str, Any]) -> bool:
|
||||||
return (
|
return str(row.get("owner_user_id") or "") == _CONVERSATION_DEPOSIT_OWNER_USER_ID and str(row.get("name") or "").strip() == _CONVERSATION_DEPOSIT_NAME
|
||||||
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:
|
def _sanitize_conversation_deposit_value(value: Any) -> Any:
|
||||||
@@ -79,9 +76,10 @@ async def llmwiki_search_tool(
|
|||||||
query: Focused natural-language retrieval query.
|
query: Focused natural-language retrieval query.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
local_config = get_app_config().llmwiki.local_wiki_index
|
||||||
resolved = get_resolved_llmwiki_runtime()
|
resolved = get_resolved_llmwiki_runtime()
|
||||||
if not resolved.weknora_enabled:
|
if not (local_config.enabled and local_config.internal_search_enabled) and not resolved.weknora_enabled:
|
||||||
return "LLMWiki retrieval is unavailable because WeKnora mode is not enabled."
|
return "LLMWiki retrieval is unavailable because no retrieval provider is enabled."
|
||||||
|
|
||||||
store = get_llmwiki_store()
|
store = get_llmwiki_store()
|
||||||
user_id = get_effective_user_id()
|
user_id = get_effective_user_id()
|
||||||
@@ -111,52 +109,26 @@ async def llmwiki_search_tool(
|
|||||||
return "No authorized LLMWiki knowledge bases are available."
|
return "No authorized LLMWiki knowledge bases are available."
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = await build_weknora_client(resolved).search(
|
payload = await get_local_wiki_retrieval_service().search(query.strip(), rows)
|
||||||
query.strip(),
|
except (RuntimeError, WikiIndexError) as exc:
|
||||||
[str(row["weknora_id"]) for row in rows],
|
code = exc.code if isinstance(exc, WikiIndexError) else "WIKI_RETRIEVAL_UNAVAILABLE"
|
||||||
)
|
return f"LLMWiki Wiki-only retrieval failed ({code}); retrieval is unavailable, not empty."
|
||||||
except (RuntimeError, WeKnoraError) as exc:
|
mapping_by_id = {str(row["id"]): row for row in rows}
|
||||||
return f"LLMWiki retrieval failed: {exc}"
|
results = []
|
||||||
|
for item in payload["results"]:
|
||||||
local_by_remote = {str(row["weknora_id"]): row for row in rows}
|
mapping = mapping_by_id.get(str(item.get("knowledge_base_id") or ""))
|
||||||
citations = []
|
visible = _sanitize_conversation_deposit_value(item) if mapping and _is_conversation_deposit_mapping(mapping) else item
|
||||||
for result in results:
|
results.append(visible)
|
||||||
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}",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{
|
{
|
||||||
"query": query,
|
"query": query,
|
||||||
"selection": [row["id"] for row in rows],
|
"selection": [row["id"] for row in rows],
|
||||||
"knowledge_base_count": len(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,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
@@ -21,7 +21,8 @@ dependencies = [
|
|||||||
"langgraph-cli>=0.4.24",
|
"langgraph-cli>=0.4.24",
|
||||||
"langgraph-runtime-inmem>=0.28.0",
|
"langgraph-runtime-inmem>=0.28.0",
|
||||||
"markdownify>=1.2.2",
|
"markdownify>=1.2.2",
|
||||||
"markitdown[all,xlsx]>=0.0.1a2",
|
"markitdown[all,xlsx]>=0.0.1a2",
|
||||||
|
"numpy>=2.0.0",
|
||||||
"pydantic>=2.12.5",
|
"pydantic>=2.12.5",
|
||||||
"pyyaml>=6.0.3",
|
"pyyaml>=6.0.3",
|
||||||
"readabilipy>=0.3.0",
|
"readabilipy>=0.3.0",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ dependencies = [
|
|||||||
"croniter>=6.2.2",
|
"croniter>=6.2.2",
|
||||||
"hindsight-client>=0.4.22",
|
"hindsight-client>=0.4.22",
|
||||||
"asyncmy>=0.2.11",
|
"asyncmy>=0.2.11",
|
||||||
|
"openpyxl>=3.1.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[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
Reference in new issue
Block a user