feat: improve knowledge sync and entity alignment

This commit is contained in:
吉鑫升 committed 2026-09-04 18:43:12 +08:00
1 parent 7dbac9cc56
commit dea4697ea0
38 files changed
+3742 -634

No files matched your search

+5 -5
View File
@@ -14,7 +14,7 @@ WeKnora;没有把业务代码发布到服务器,没有提交 Git,也没有
- 实现:FastEmbed / ONNX Runtime CPU,官方 `BAAI/bge-m3` 权重。
- 配置名:`bge-embedding-m3`,1024 维,每批 2 条,2 个 CPU 推理线程。
- 运行依赖:后端 `local-embedding` extra;当前本地虚拟环境已安装。
- 运行依赖:FastEmbed 随标准后端依赖安装;模型必须提前放在离线目录中,仍按需加载,不影响主服务启动。
- 权重:约 2.3 GB,持久缓存 `C:\Users\ji\.cache\deerflow\embeddings`,不进入业务仓库。
- 配置:`llmwiki.local_wiki_index.embedding.provider: local`;可用
`local_model_path` 指定离线权重目录,详见后端 README。
@@ -101,15 +101,15 @@ WeKnora;没有把业务代码发布到服务器,没有提交 Git,也没有
多来源事实冲突合并验收,不能视为这一历史需求已全面完成。
4. 重复上传仍会保留独立来源记录。向量导出已消除旧版本重复,但上传身份幂等、部分远端
写入失败后的无损重试、跨实例图片资源打包仍需独立验证。
5. 自动沉淀挂在本系统成功向量化完成后;未新增文件监听或定时远程写入。直接在外部
WeKnora 修改的内容仍需通过本系统同步/向量化入口接入。
5. 当前版本已补充自动增量链路:服务启动立即补扫,之后默认每 30 秒扫描全部普通
知识库;Wiki 抽取完成后无需手工点击向量化,并自动以来源快照覆盖同步到知识梳理总库。
6. 回流当前只允许新建空 Wiki 普通库,避免覆盖已有页面。普通库现有页面列表的超大规模
分页与助手问答的最终生成回答不属于这轮通过结论。
## 自动化检查
- 后端相关测试:46 passed(assistant repository、Wiki sync、package roundtrip、技能扫描、
技能关系仓储、知识库管理)。
- 后端相关测试:118 passed(assistant repository、Wiki sync、package roundtrip、
WeKnora 接口、向量检索与知识库管理)。
- 修改的 Python 功能文件 Ruff 检查通过;app.py 既有启动顺序导致的 E402 不在此次改动中处理。
- `git diff --check` 通过(只有仓库换行提示)。
- 前端完整 `pnpm typecheck` 未通过:57 个既有/本轮范围外错误,涉及 nextra 缺失、Markdown
+13 -1
View File
@@ -56,7 +56,7 @@ type PageSidebarProps = {
headerHidden?: boolean;
};
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "encoder" | "about";
const hasActiveChild = (item: MenuItem, pathname: string): boolean =>
menuItemContainsPath(item, pathname);
@@ -270,6 +270,12 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => openSettings("encoder")}>
<Cpu className="size-4" />
本地编码器
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
@@ -585,6 +591,12 @@ function LegacyAccordionSidebar({
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => openSettings("encoder")}>
<Cpu className="size-4" />
本地编码器
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
<ShieldCheck className="size-4" />
@@ -1,6 +1,6 @@
"use client";
import { BellIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
import { BellIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
@@ -14,16 +14,19 @@ import { AboutSettingsPage } from "@/components/workspace/settings/about-setting
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
type SettingsSection =
| "account"
| "appearance"
| "memory"
| "tools"
| "about";
| "memory"
| "tools"
| "encoder"
| "about";
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
defaultSection?: SettingsSection;
@@ -31,7 +34,8 @@ type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
export function SettingsDialog(props: SettingsDialogProps) {
const { defaultSection = "appearance", ...dialogProps } = props;
const { t } = useI18n();
const { t } = useI18n();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [activeSection, setActiveSection] =
useState<SettingsSection>(defaultSection);
@@ -63,7 +67,10 @@ export function SettingsDialog(props: SettingsDialogProps) {
// label: t.settings.sections.memory,
// icon: BrainIcon,
// },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin
? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
: []),
{
id: "account",
label: "个人信息",
@@ -75,7 +82,8 @@ export function SettingsDialog(props: SettingsDialogProps) {
t.settings.sections.appearance,
t.settings.sections.memory,
t.settings.sections.tools,
t.settings.sections.about,
t.settings.sections.about,
isAdmin,
],
);
return (
@@ -125,7 +133,8 @@ export function SettingsDialog(props: SettingsDialogProps) {
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
)}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
{activeSection === "about" && <AboutSettingsPage />}
</div>
</ScrollArea>
@@ -102,9 +102,16 @@ import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
import { SkillStateSheet } from "./skill-state-sheet";
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
const { t } = useI18n();
return (
export function SkillSettingsPage({
onClose,
embedded = false,
}: {
onClose?: () => void;
embedded?: boolean;
} = {}) {
const { t } = useI18n();
if (embedded) return <SkillSettingsList onClose={onClose} />;
return (
<SettingsSection
title={t.settings.skills.title}
description={t.settings.skills.description}
@@ -1,4 +1,4 @@
import { Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
import { CheckCircle2, Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -8,8 +8,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useAllSkillsAdmin } from "@/core/skills/hooks";
import { useAllSkillsAdmin, useSkills } from "@/core/skills/hooks";
import { useLlmWikiKnowledgeBases, useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import {
type SkillKnowledgeTarget,
@@ -33,17 +32,29 @@ const PHASE_LABELS: Record<string, string> = {
completed: "完成",
};
export function SkillKnowledgePanel() {
const { skills, isLoading } = useAllSkillsAdmin(true);
const STATUS_LABELS: Record<string, string> = {
queued: "等待处理",
running: "处理中",
awaiting_review: "等待管理员审核",
completed: "已完成",
partial_failed: "部分失败",
failed: "失败",
canceled: "已取消",
};
export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean }) {
const visibleSkills = useSkills();
const allSkills = useAllSkillsAdmin(adminMode);
const skills = adminMode ? allSkills.skills : visibleSkills.skills;
const isLoading = adminMode ? allSkills.isLoading : visibleSkills.isLoading;
const liveSkills = useMemo(() => skills.filter((skill) => (skill.state ?? "active") === "active"), [skills]);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [skillQuery, setSkillQuery] = useState("");
const [selectedTargetKey, setSelectedTargetKey] = useState<string | null>(null);
const [reviewMode, setReviewMode] = useState<"auto_high_confidence" | "off">("off");
const runtime = useLlmWikiRuntime();
const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora");
const bindings = useSkillKnowledgeBindings();
const jobs = useSkillKnowledgeJobs();
const bindings = useSkillKnowledgeBindings(undefined, !adminMode);
const jobs = useSkillKnowledgeJobs(!adminMode);
const actions = useSkillKnowledgeActions();
const targets = useMemo<SkillKnowledgeTarget[]>(() => {
@@ -122,7 +133,7 @@ export function SkillKnowledgePanel() {
const job = await actions.createJob.mutateAsync({
skill_names: selectedSkills,
targets: selectedTargets,
review_mode: reviewMode,
review_mode: "off",
});
toast.success(`已创建 ${job.total_count} 个归纳项`);
setSelectedSkills([]);
@@ -132,17 +143,17 @@ export function SkillKnowledgePanel() {
};
return (
<div className="grid size-full min-h-0 grid-cols-[minmax(320px,0.9fr)_minmax(420px,1.1fr)] gap-4 overflow-hidden p-6">
<div className="grid size-full min-h-0 gap-4 overflow-auto p-4 lg:grid-cols-[minmax(320px,0.9fr)_minmax(420px,1.1fr)] lg:overflow-hidden lg:p-6">
<section className="flex min-h-0 flex-col rounded-lg border bg-card">
<div className="border-b p-4">
<h2 className="font-semibold">技能到知识库管理</h2>
<p className="mt-1 text-xs text-muted-foreground">一次选择多个技能归纳到一个 WeKnora Wiki 知识库;代码与脚本不会被读取。</p>
<h2 className="font-semibold">归纳技能知识</h2>
<p className="mt-1 text-xs leading-5 text-muted-foreground">选择技能和一个目标知识库后,系统会在后台提取 Markdown 等知识资料,忽略 Python 等代码文件,自动生成 Wiki、关系和向量索引。</p>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-4">
<div>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">1. 选择技能({selectedSkills.length})</span>
<span className="text-sm font-medium">第一步:选择技能(已选 {selectedSkills.length})</span>
<Button variant="ghost" size="sm" onClick={toggleFilteredSkills}>
{allFilteredSelected ? "清空当前结果" : "选择当前结果"}
</Button>
@@ -162,6 +173,13 @@ export function SkillKnowledgePanel() {
<Checkbox checked={selectedSkills.includes(skill.name)} onCheckedChange={() => toggle(selectedSkills, skill.name, setSelectedSkills)} />
<span className="truncate text-sm">{skill.name_zh || skill.name}</span>
<span className="ml-auto truncate text-xs text-muted-foreground">{skill.name}</span>
{(bindings.data ?? []).some((binding) => binding.skill_name === skill.name) && (
<Badge variant="secondary" className="shrink-0 text-[10px]">
{(bindings.data ?? []).some((binding) => binding.skill_name === skill.name && `${binding.target_type}:${binding.target_id}` === selectedTargetKey)
? "已在目标库"
: `已归纳 ${(bindings.data ?? []).filter((binding) => binding.skill_name === skill.name).length} 库`}
</Badge>
)}
</label>
))}
{!isLoading && !filteredSkills.length && (
@@ -174,7 +192,7 @@ export function SkillKnowledgePanel() {
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">2. 选择目标知识库({selectedTargetKey ? 1 : 0})</span>
<span className="text-sm font-medium">第二步:选择一个目标知识库</span>
</div>
<div className="space-y-1 rounded-md border p-2">
{targets.map((target) => {
@@ -207,19 +225,12 @@ export function SkillKnowledgePanel() {
)}
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-medium">审核策略</span>
<Select value={reviewMode} onValueChange={(value) => setReviewMode(value as typeof reviewMode)}>
<SelectTrigger className="w-52"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="auto_high_confidence">高置信自动通过</SelectItem>
<SelectItem value="off">不阻塞写入</SelectItem>
</SelectContent>
</Select>
<div className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及同步到“知识梳理”总库均在后台完成。
</div>
<Button className="w-full" disabled={actions.createJob.isPending} onClick={() => void submit()}>
<Button className="w-full" disabled={actions.createJob.isPending || !selectedSkills.length || !selectedTargetKey} onClick={() => void submit()}>
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
开始归纳({selectedSkills.length * (selectedTargetKey ? 1 : 0)} 项)
开始归纳 {selectedSkills.length ? `${selectedSkills.length} 个技能` : ""}
</Button>
</div>
</ScrollArea>
@@ -228,22 +239,26 @@ export function SkillKnowledgePanel() {
<section className="flex min-h-0 flex-col gap-4 overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col rounded-lg border bg-card">
<div className="flex items-center justify-between border-b p-4">
<div><h2 className="font-semibold">归纳任务</h2><p className="text-xs text-muted-foreground">后台执行、失败可单项重试</p></div>
<div><h2 className="font-semibold">我的归纳任务</h2><p className="text-xs text-muted-foreground">任务会自动刷新,失败项可直接重试</p></div>
<Button size="icon" variant="ghost" onClick={() => void jobs.refetch()}><RefreshCw className="size-4" /></Button>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-3 p-4">
{(jobs.data ?? []).map((job) => {
const done = job.success_count + job.partial_failed_count + job.failed_count;
const countedDone = job.success_count + job.partial_failed_count + job.failed_count;
const itemDone = (job.items ?? []).filter((item) =>
["completed", "partial_failed", "failed", "canceled"].includes(item.status),
).length;
const done = Math.max(countedDone, itemDone);
const progress = job.total_count ? Math.round((done / job.total_count) * 100) : 0;
return (
<div key={job.id} className="rounded-md border p-3">
<div className="flex items-center gap-2 text-sm"><Badge variant={job.status === "completed" ? "default" : "secondary"}>{job.status}</Badge><span>{done}/{job.total_count} 项</span><span className="ml-auto text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</span></div>
<div className="flex items-center gap-2 text-sm"><Badge variant={job.status === "completed" ? "default" : "secondary"}>{STATUS_LABELS[job.status] || job.status}</Badge><span>{done}/{job.total_count} 项</span><span className="ml-auto text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</span></div>
<Progress className="my-2" value={progress} />
{job.items?.map((item) => (
<div key={item.id} className="mt-2 flex items-center gap-2 border-t pt-2 text-xs">
<span className="max-w-36 truncate font-medium">{item.skill_name}</span>
<span className="text-muted-foreground">{PHASE_LABELS[item.phase] || item.phase}</span>
<span className="text-muted-foreground">{PHASE_LABELS[item.phase] || STATUS_LABELS[item.status] || item.phase}</span>
<span className="ml-auto">{item.progress}%</span>
{item.status === "failed" && <Button size="sm" variant="outline" onClick={() => actions.retry.mutate(item.id)}>重试</Button>}
</div>
@@ -256,8 +271,8 @@ export function SkillKnowledgePanel() {
</ScrollArea>
</div>
<div className="max-h-80 overflow-auto rounded-lg border bg-card p-3 text-xs">
<div className="mb-2 text-muted-foreground">当前有效绑定:{bindings.data?.length ?? 0};同名同类型实体按来源贡献合并。</div>
<div className="space-y-2">{bindings.data?.map((binding) => <div key={binding.id} className="flex flex-wrap items-center gap-2 rounded border p-2"><span className="max-w-32 truncate font-medium">{binding.skill_name}</span><span className="max-w-40 truncate text-muted-foreground">→ {binding.target_name}</span><Badge variant="outline">{binding.status}</Badge><code className="max-w-40 truncate text-[10px] text-muted-foreground">{binding.remote_business_key}</code><Button size="sm" variant="outline" className="ml-auto" disabled={actions.resync.isPending} onClick={() => actions.resync.mutate(binding.id)}>重归纳</Button><Button size="sm" variant="ghost" onClick={() => { if (window.confirm("解除关联并保留目标知识内容?")) actions.detach.mutate({ id: binding.id, deleteContributions: false }); }}>解绑并保留</Button><Button size="sm" variant="destructive" onClick={() => { if (window.confirm("解除关联并删除此技能贡献?共享知识仍会保留其他来源。")) actions.detach.mutate({ id: binding.id, deleteContributions: true }); }}>解绑并删除贡献</Button></div>)}</div>
<div className="mb-2 flex items-center gap-2 text-muted-foreground"><CheckCircle2 className="size-4 text-emerald-600" />已归纳关联:{bindings.data?.length ?? 0}</div>
<div className="space-y-2">{bindings.data?.map((binding) => <div key={binding.id} className="flex flex-wrap items-center gap-2 rounded border p-2"><span className="max-w-40 truncate font-medium">{binding.skill_name}</span><span className="max-w-48 truncate text-muted-foreground">已归纳到 {binding.target_name}</span><Badge variant="outline">{STATUS_LABELS[binding.status] || binding.status}</Badge><Button size="sm" variant="outline" className="ml-auto" disabled={actions.resync.isPending} onClick={() => actions.resync.mutate(binding.id)}>重新归纳</Button><Button size="sm" variant="ghost" onClick={() => { if (window.confirm("解除关联并保留目标知识内容?")) actions.detach.mutate({ id: binding.id, deleteContributions: false }); }}>解除关联</Button>{adminMode && <Button size="sm" variant="destructive" onClick={() => { if (window.confirm("解除关联并删除此技能贡献?共享知识仍会保留其他来源。")) actions.detach.mutate({ id: binding.id, deleteContributions: true }); }}>删除贡献</Button>}</div>)}</div>
</div>
</section>
</div>
@@ -83,7 +83,7 @@ export function WorkspaceNavMenu() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
"appearance" | "memory" | "tools" | "about"
"appearance" | "memory" | "tools" | "encoder" | "about"
>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
@@ -189,6 +189,17 @@ export function WorkspaceNavMenu() {
<Settings2Icon />
{t.common.settings}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => {
setSettingsDefaultSection("encoder");
setSettingsOpen(true);
}}
>
<CpuIcon />
本地编码器
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
@@ -254,14 +265,6 @@ export function WorkspaceNavMenu() {
技能管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skills?tab=knowledge")}
>
<DatabaseZapIcon />
技能到知识库管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/models")}
+1 -13
View File
@@ -3,7 +3,6 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
DatabaseZap,
Replace,
SearchIcon,
SparklesIcon,
@@ -20,7 +19,6 @@ import {
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { SkillRow } from "@/components/workspace/settings/skill-settings-page";
import { SkillKnowledgePanel } from "@/components/workspace/skills/SkillKnowledgePanel";
import { getStoredAuth } from "@/core/auth";
import { useAllSkillsAdmin } from "@/core/skills/hooks";
import { desensitize } from "@/lib/desensitize";
@@ -37,7 +35,7 @@ export default function AdminSkillsPage() {
const knowledgeBindings = useSkillKnowledgeBindings();
const activeTab = useMemo(() => {
const tab = searchParams.get("tab");
return tab === "addresses" || tab === "knowledge" ? tab : "skills";
return tab === "addresses" ? tab : "skills";
}, [searchParams]);
const changeTab = (value: string) => {
@@ -89,10 +87,6 @@ export default function AdminSkillsPage() {
<Replace />
技能地址管理
</TabsTrigger>
<TabsTrigger value="knowledge">
<DatabaseZap />
技能到知识库管理
</TabsTrigger>
</TabsList>
</div>
</div>
@@ -189,12 +183,6 @@ export default function AdminSkillsPage() {
>
<SkillAddressesPanel />
</TabsContent>
<TabsContent
value="knowledge"
className="mt-0 min-h-0 flex-1 overflow-hidden"
>
<SkillKnowledgePanel />
</TabsContent>
</Tabs>
</WorkspaceBody>
</WorkspaceContainer>
+233 -32
View File
@@ -2,10 +2,12 @@ import {
ArrowLeft,
BookOpenText,
Database,
Edit3,
Loader2,
Plus,
RefreshCw,
Search,
Trash2,
Upload,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -17,12 +19,14 @@ import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { KnowledgeProductNav } from "@/components/workspace/knowledge-product-nav";
import { getStoredAuth } from "@/core/auth";
import { buildAppHashUrl } from "@/core/llmwiki/source-links";
@@ -33,6 +37,7 @@ import {
useAssistantKnowledgeBases,
useAssistantKnowledgeSources,
useAssistantWikiPages,
type AssistantKnowledgeBase,
} from "@/strategy-components/api/assistant-knowledge";
import {
WikiLibraryReader,
@@ -53,6 +58,8 @@ export default function AssistantKnowledgePage() {
const client = useQueryClient();
const [tab, setTab] = useState("wiki");
const [createOpen, setCreateOpen] = useState(false);
const [editingBase, setEditingBase] =
useState<AssistantKnowledgeBase | null>(null);
const [name, setName] = useState("");
const [query, setQuery] = useState("");
const [searching, setSearching] = useState(false);
@@ -124,6 +131,25 @@ export default function AssistantKnowledgePage() {
};
const refresh = () =>
void client.invalidateQueries({ queryKey: ["assistant-knowledge"] });
const removeBase = async (target: AssistantKnowledgeBase) => {
if (target.is_global) {
toast.error("知识梳理总库承担全库沉淀,不能删除");
return;
}
if (
!window.confirm(
`确定删除助手知识库“${target.name}”吗?库内 Wiki、向量、实体、关系和导入记录都会删除。`,
)
)
return;
try {
await actions.deleteBase.mutateAsync(target.id);
if (baseId === target.id) setParams({});
toast.success("助手知识库已删除");
} catch (error) {
toast.error((error as Error).message);
}
};
return (
<div className="flex size-full min-h-0 flex-col bg-background">
<KnowledgeProductNav />
@@ -150,34 +176,69 @@ export default function AssistantKnowledgePage() {
)}
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{bases.data?.map((b) => (
<button
<div
key={b.id}
onClick={() => setParams({ base_id: b.id })}
className="group rounded-2xl border bg-card p-6 text-left shadow-sm transition hover:border-primary/40 hover:shadow-md"
className="group overflow-hidden rounded-2xl border bg-card text-left shadow-sm transition hover:border-primary/40 hover:shadow-md"
>
<div className="mb-5 flex items-center justify-between">
<div className="rounded-xl bg-emerald-100 p-3 text-emerald-700 dark:bg-emerald-950">
<Database className="size-6" />
<button
type="button"
onClick={() => setParams({ base_id: b.id })}
className="w-full p-6 text-left"
>
<div className="mb-5 flex items-center justify-between">
<div className="rounded-xl bg-emerald-100 p-3 text-emerald-700 dark:bg-emerald-950">
<Database className="size-6" />
</div>
{b.is_global && (
<Badge variant="secondary">全库知识梳理</Badge>
)}
</div>
{b.is_global && (
<Badge variant="secondary">全库知识梳理</Badge>
)}
</div>
<h2 className="text-lg font-semibold group-hover:text-primary">
{b.name}
</h2>
<p className="mt-2 line-clamp-2 min-h-10 text-sm text-muted-foreground">
{b.is_global
? "汇聚普通知识库的分析成果,保留分类、标签和知识关联。"
: b.description ||
"独立的 Wiki 知识空间,支持导入、导出与向量检索。"}
</p>
<div className="mt-6 flex gap-5 text-xs text-muted-foreground">
<span>{b.page_count || 0} Wiki</span>
<span>{b.source_count || 0} 来源</span>
<span>{b.relation_count || 0} 关系</span>
</div>
</button>
<h2 className="text-lg font-semibold group-hover:text-primary">
{b.name}
</h2>
<p className="mt-2 line-clamp-2 min-h-10 text-sm text-muted-foreground">
{b.description ||
(b.is_global
? "汇聚普通知识库的分析成果,保留分类、标签和知识关联。"
: "独立的 Wiki 知识空间,支持导入、导出与向量检索。")}
</p>
<div className="mt-6 flex gap-5 text-xs text-muted-foreground">
<span>{b.page_count || 0} Wiki</span>
<span>{b.source_count || 0} 来源</span>
<span>{b.relation_count || 0} 关系</span>
</div>
</button>
{isAdmin && (
<div className="flex items-center gap-2 border-t bg-muted/20 px-6 py-3">
<Button
size="sm"
variant="outline"
disabled={actions.updateBase.isPending}
onClick={() => setEditingBase(b)}
>
<Edit3 className="mr-2 size-4" />
编辑
</Button>
{!b.is_global && (
<Button
size="sm"
variant="ghost"
className="ml-auto text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={actions.deleteBase.isPending}
onClick={() => void removeBase(b)}
>
<Trash2 className="mr-2 size-4" />
删除
</Button>
)}
{b.is_global && (
<span className="ml-auto text-xs text-muted-foreground">
系统大库不可删除
</span>
)}
</div>
)}
</div>
))}
</div>
{!bases.isLoading && !bases.data?.length && isAdmin && (
@@ -222,6 +283,29 @@ export default function AssistantKnowledgePage() {
<RefreshCw className="mr-2 size-4" />
刷新
</Button>
{isAdmin && (
<Button
variant="outline"
size="sm"
disabled={!base || actions.updateBase.isPending}
onClick={() => base && setEditingBase(base)}
>
<Edit3 className="mr-2 size-4" />
编辑
</Button>
)}
{isAdmin && base && !base.is_global && (
<Button
variant="outline"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={actions.deleteBase.isPending}
onClick={() => void removeBase(base)}
>
<Trash2 className="mr-2 size-4" />
删除
</Button>
)}
{isAdmin && (
<Button
variant="outline"
@@ -265,7 +349,7 @@ export default function AssistantKnowledgePage() {
<TabsTrigger value="wiki">
Wiki({base?.page_count || 0})
</TabsTrigger>
<TabsTrigger value="search">向量检索</TabsTrigger>
<TabsTrigger value="search">知识检索</TabsTrigger>
<TabsTrigger value="sources">来源与同步</TabsTrigger>
{isAdmin && (
<TabsTrigger value="transfer">导入 / 导出记录</TabsTrigger>
@@ -274,6 +358,7 @@ export default function AssistantKnowledgePage() {
</div>
<TabsContent value="wiki" className="mt-0 flex min-h-0 flex-1">
<WikiLibraryReader
key={baseId}
pages={libraryPages}
activePage={active}
onNavigate={navigatePage}
@@ -285,6 +370,7 @@ export default function AssistantKnowledgePage() {
mappingId={
active?.origins?.find((o) => o.mapping_id)?.mapping_id
}
initiallyCollapseTree
/>
</TabsContent>
<TabsContent value="search" className="overflow-auto p-6">
@@ -310,26 +396,36 @@ export default function AssistantKnowledgePage() {
<>
<Badge variant="outline">
{searchResult.retrieval_mode === "vector"
? "向量检索 · 余弦相似度"
? "知识正文 / 片段 · 向量检索"
: searchResult.retrieval_mode === "empty"
? "没有匹配结果"
: "关键词检索(暂无匹配模型的向量)"}
: "知识正文 / 片段 · 关键词检索(暂无匹配模型的向量)"}
</Badge>
<div className="mt-4 space-y-3">
{searchResult.results.map((p) => (
<button
key={p.id}
onClick={() => navigatePage(p.slug)}
className="block w-full max-w-4xl rounded-xl border p-5 text-left hover:bg-muted"
type="button"
onClick={() => {
if (p.page_type !== "chunk") navigatePage(p.slug);
}}
className={`block w-full max-w-4xl rounded-xl border p-5 text-left ${p.page_type === "chunk" ? "cursor-default" : "hover:bg-muted"}`}
>
<div className="font-medium">
{p.title}
{p.page_type === "chunk" && (
<Badge variant="secondary" className="ml-2">
知识片段
</Badge>
)}
<span className="float-right text-xs text-muted-foreground">
{p.score?.toFixed(4)}
</span>
</div>
<p className="mt-2 line-clamp-3 text-sm text-muted-foreground">
{p.summary || p.content_markdown}
{p.matched_section?.content ||
p.matched_content ||
p.content_markdown}
</p>
</button>
))}
@@ -425,6 +521,111 @@ export default function AssistantKnowledgePage() {
</DialogFooter>
</DialogContent>
</Dialog>
{editingBase && (
<EditAssistantBaseDialog
key={editingBase.id}
base={editingBase}
pending={actions.updateBase.isPending}
onClose={() => setEditingBase(null)}
onSave={async (input) => {
try {
await actions.updateBase.mutateAsync({
id: editingBase.id,
...input,
});
setEditingBase(null);
toast.success("助手知识库信息已保存");
} catch (error) {
toast.error((error as Error).message);
}
}}
/>
)}
</div>
);
}
function EditAssistantBaseDialog({
base,
pending,
onClose,
onSave,
}: {
base: AssistantKnowledgeBase;
pending: boolean;
onClose: () => void;
onSave: (input: { name: string; description: string }) => Promise<void>;
}) {
const [name, setName] = useState(base.name);
const [description, setDescription] = useState(base.description || "");
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !pending) onClose();
}}
>
<DialogContent>
<form
onSubmit={(event) => {
event.preventDefault();
if (!pending && name.trim()) {
void onSave({
name: name.trim(),
description: description.trim(),
});
}
}}
>
<DialogHeader>
<DialogTitle>编辑助手知识库</DialogTitle>
<DialogDescription>
修改知识库名称和描述。知识内容、向量和关联关系不会受到影响。
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="assistant-base-name">
名称
</label>
<Input
id="assistant-base-name"
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={255}
disabled={pending}
required
/>
</div>
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor="assistant-base-description"
>
描述
</label>
<Textarea
id="assistant-base-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
maxLength={2048}
rows={4}
disabled={pending}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" disabled={pending} onClick={onClose}>
取消
</Button>
<Button type="submit" disabled={pending || !name.trim()}>
{pending && <Loader2 className="mr-2 size-4 animate-spin" />}
保存
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+49 -3
View File
@@ -1,9 +1,55 @@
import { BookOpenCheck, Sparkles } from "lucide-react";
import { useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
import { SkillKnowledgePanel } from "@/components/workspace/skills/SkillKnowledgePanel";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export default function SkillsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const tab = useMemo(
() => (searchParams.get("tab") === "knowledge" ? "knowledge" : "skills"),
[searchParams],
);
const changeTab = (value: string) => {
const next = new URLSearchParams(searchParams);
if (value === "knowledge") next.set("tab", "knowledge");
else next.delete("tab");
setSearchParams(next, { replace: true });
};
return (
<div className="flex size-full min-h-0 flex-col overflow-auto p-6">
<SkillSettingsPage />
</div>
<Tabs
value={tab}
onValueChange={changeTab}
className="flex size-full min-h-0 flex-col gap-0 overflow-hidden"
>
<header className="flex flex-wrap items-center justify-between gap-4 border-b bg-background px-6 py-4">
<div>
<h1 className="text-xl font-semibold">技能</h1>
<p className="mt-1 text-sm text-muted-foreground">
浏览和维护技能,也可以把多个技能的知识一次归纳到一个 Wiki 知识库。
</p>
</div>
<TabsList>
<TabsTrigger value="skills">
<Sparkles className="mr-2 size-4" />
技能列表
</TabsTrigger>
<TabsTrigger value="knowledge">
<BookOpenCheck className="mr-2 size-4" />
归纳到知识库
</TabsTrigger>
</TabsList>
</header>
<TabsContent value="skills" className="mt-0 min-h-0 flex-1 overflow-auto p-6">
<SkillSettingsPage embedded />
</TabsContent>
<TabsContent value="knowledge" className="mt-0 min-h-0 flex-1 overflow-hidden">
<SkillKnowledgePanel />
</TabsContent>
</Tabs>
);
}
+168 -123
View File
@@ -1,85 +1,92 @@
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import {
Navigate,
Route,
Routes,
useLocation,
useParams,
} from "react-router-dom";
import { getStoredAuth } from '@/core/auth';
import { getAPIClient } from '@/core/api';
import { isSentimentAgentVisible } from '@/core/sentiment-agent/config';
import { getStoredAuth } from "@/core/auth";
import { getAPIClient } from "@/core/api";
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
import {
resolveChatsIframeEntryRedirect,
resolveStandardChatFallback,
} from '@/core/page-layout/chats-iframe-route';
import { isEmbeddedHost } from '@/core/embed/frame-context';
} from "@/core/page-layout/chats-iframe-route";
import { isEmbeddedHost } from "@/core/embed/frame-context";
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import {
CompactShortcutButtons,
LayoutModeSwitcher,
ThemeSwitcher,
} from '@/components/workspace/appearance-controls';
import { WorkspaceSidebar } from '@/components/workspace/workspace-sidebar';
import { UserIdentityBadge } from '@/components/workspace/user-identity-badge';
import { CommandPalette } from '@/components/workspace/command-palette';
import { Toaster } from 'sonner';
import { SubtasksProvider } from '@/core/tasks/context';
import { ArtifactsProvider } from '@/components/workspace/artifacts';
import { DocumentRewritePresentationProvider } from '@/core/document-rewrite/context';
} from "@/components/workspace/appearance-controls";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
import { CommandPalette } from "@/components/workspace/command-palette";
import { Toaster } from "sonner";
import { SubtasksProvider } from "@/core/tasks/context";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
import { RouteBaseProvider } from '@/core/route-base';
import { agentRefOfThread } from '@/core/threads/utils';
import { RouteBaseProvider } from "@/core/route-base";
import { agentRefOfThread } from "@/core/threads/utils";
import ChatsPage from './ChatsPage';
import ChatPage from './ChatPage';
import ArtifactLibraryPage from './ArtifactLibraryPage';
import IframeChatPage from './IframeChatPage';
import AgentsPage from './AgentsPage';
import AgentChatPage from './AgentChatPage';
import SentimentAgentChatPage from './SentimentAgentChatPage';
import NewAgentPage from './NewAgentPage';
import AdminAgentsPage from './AdminAgentsPage';
import AdminScheduledTasksPage from './AdminScheduledTasksPage';
import AdminSkillsPage from './AdminSkillsPage';
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
import ScheduledTasksPage from './ScheduledTasksPage';
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
import AssistantKnowledgePage from './AssistantKnowledgePage';
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
import WeKnoraWikiPageView from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView';
import MarkdownDocxEditorPage from './MarkdownDocxEditorPage';
import LlmMetricsPage from './LlmMetricsPage';
import CheckpointMigrationPage from './CheckpointMigrationPage';
import MemoryPage from './MemoryPage';
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
import ModelConcurrencySettingsPage from './ModelConcurrencySettingsPage';
import ModelManagementPage from './ModelManagementPage';
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
import SkillEvolutionPage from './SkillEvolutionPage';
import SkillsPage from './SkillsPage';
import UsersPage from './UsersPage';
import AdminUserActivityPage from './AdminUserActivityPage';
import AdminMemoryPage from './AdminMemoryPage';
import AdminUserHubPage from './AdminUserHubPage';
import TagsPage from './TagsPage';
import PositionsPage from './PositionsPage';
import PositionRolesPage from './PositionRolesPage';
import PluginManagementPage from './PluginManagementPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import DeepResearchPage from './DeepResearchPage';
import ReportStructureCreatePage from './deep-research/ReportStructureCreatePage';
import EnterpriseResearchPage from './EnterpriseResearchPage';
import StudioNotebooksPage from './StudioNotebooksPage';
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
import PositionRoundtablePage from '@/position-roundtable/pages/PositionRoundtablePage';
import EightBfRoundtablePage from '@/eightbf-roundtable/pages/EightBfRoundtablePage';
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
import '@/roundtable-planning/styles/roundtable-planning.css';
import { cn } from '@/lib/utils';
import ChatsPage from "./ChatsPage";
import ChatPage from "./ChatPage";
import ArtifactLibraryPage from "./ArtifactLibraryPage";
import IframeChatPage from "./IframeChatPage";
import AgentsPage from "./AgentsPage";
import AgentChatPage from "./AgentChatPage";
import SentimentAgentChatPage from "./SentimentAgentChatPage";
import NewAgentPage from "./NewAgentPage";
import AdminAgentsPage from "./AdminAgentsPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminSkillsPage from "./AdminSkillsPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
import LlmWikiKnowledgePage from "./LlmWikiKnowledgePage";
import AssistantKnowledgePage from "./AssistantKnowledgePage";
import WeKnoraWikiManagementPage from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage";
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
import WeKnoraPackageTransfersPage from "@/strategy-components/components/resource-management/llmwiki/WeKnoraPackageTransfersPage";
import MarkdownDocxEditorPage from "./MarkdownDocxEditorPage";
import LlmMetricsPage from "./LlmMetricsPage";
import CheckpointMigrationPage from "./CheckpointMigrationPage";
import MemoryPage from "./MemoryPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage";
import ModelManagementPage from "./ModelManagementPage";
import PublishSquaresSettingsPage from "./PublishSquaresSettingsPage";
import SkillCompressionSettingsPage from "./SkillCompressionSettingsPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import AdminMemoryPage from "./AdminMemoryPage";
import AdminUserHubPage from "./AdminUserHubPage";
import TagsPage from "./TagsPage";
import PositionsPage from "./PositionsPage";
import PositionRolesPage from "./PositionRolesPage";
import PluginManagementPage from "./PluginManagementPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
import DeepResearchPage from "./DeepResearchPage";
import ReportStructureCreatePage from "./deep-research/ReportStructureCreatePage";
import EnterpriseResearchPage from "./EnterpriseResearchPage";
import StudioNotebooksPage from "./StudioNotebooksPage";
import StudioNotebookDetailPage from "./StudioNotebookDetailPage";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage";
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
import DispatchChainPreviewPage from "@/roundtable-planning/pages/DispatchChainPreviewPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
import { cn } from "@/lib/utils";
function NotebookDetailPageWithKey() {
const { notebookId } = useParams<{ notebookId: string }>();
@@ -94,12 +101,12 @@ function NewAgentPageWithKey() {
function shouldShowCompactTopActions(pathname: string): boolean {
return (
pathname === '/page/workspace/agents' ||
pathname === '/page/workspace/skills' ||
pathname === '/page/workspace/memory' ||
pathname === '/page/workspace/library' ||
pathname === '/page/workspace/knowledge' ||
pathname.startsWith('/page/workspace/knowledge/')
pathname === "/page/workspace/agents" ||
pathname === "/page/workspace/skills" ||
pathname === "/page/workspace/memory" ||
pathname === "/page/workspace/library" ||
pathname === "/page/workspace/knowledge" ||
pathname.startsWith("/page/workspace/knowledge/")
);
}
@@ -157,47 +164,52 @@ export function WorkspaceLayout({
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
const pathname = useLocation().pathname;
const isGeneralQa = pathname.startsWith('/page/workspace/chats');
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
const showCompactTopActions =
headerHidden && !compactActionsHidden && shouldShowCompactTopActions(pathname);
headerHidden &&
!compactActionsHidden &&
shouldShowCompactTopActions(pathname);
const pageSidebarWidth = pageSidebarHidden
? '0rem'
? "0rem"
: pageSidebarDark
? pageSidebarCollapsed
? '0rem'
: '118px'
? "0rem"
: "118px"
: pageSidebarCollapsed
? '4rem'
: '16rem';
? "4rem"
: "16rem";
return (
<SidebarProvider
className={[
'relative h-full w-full overflow-x-hidden',
'[&_[data-slot=sidebar-container]]:absolute',
'[&_[data-slot=sidebar-container]]:left-0',
'[&_[data-slot=sidebar-container]]:top-0',
].join(' ')}
"relative h-full w-full overflow-x-hidden",
"[&_[data-slot=sidebar-container]]:absolute",
"[&_[data-slot=sidebar-container]]:left-0",
"[&_[data-slot=sidebar-container]]:top-0",
].join(" ")}
style={
{
'--page-sidebar-width': pageSidebarWidth,
'--header-height': headerHidden ? '0rem' : '4rem',
"--page-sidebar-width": pageSidebarWidth,
"--header-height": headerHidden ? "0rem" : "4rem",
} as CSSProperties
}
// Agent and general-chat routes share this layout component. Keep their
// sidebar state separate: otherwise React preserves the collapsed agent
// state when a history item switches to `/chats/:thread_id`, while the
// new page's header button is meant to control GeneralQaSidebar.
key={`${String(compactWorkspaceSidebar)}:${isGeneralQa ? 'general' : 'workspace'}`}
key={`${String(compactWorkspaceSidebar)}:${isGeneralQa ? "general" : "workspace"}`}
data-page-sidebar-collapsed={pageSidebarCollapsed}
defaultOpen={
forceWorkspaceSidebarCollapsed
? false
: (defaultHistorySidebarOpen ?? (isGeneralQa || !compactWorkspaceSidebar))
: (defaultHistorySidebarOpen ??
(isGeneralQa || !compactWorkspaceSidebar))
}
open={forceWorkspaceSidebarCollapsed ? false : undefined}
onOpenChange={forceWorkspaceSidebarCollapsed ? () => undefined : undefined}
onOpenChange={
forceWorkspaceSidebarCollapsed ? () => undefined : undefined
}
disableToggle={forceWorkspaceSidebarCollapsed}
>
{!workspaceSidebarHidden && (
@@ -207,7 +219,7 @@ export function WorkspaceLayout({
/>
)}
<SidebarInset
className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')}
className={cn("min-w-0", insetOverflowHidden && "overflow-hidden")}
>
{showCompactTopActions ? (
<>
@@ -244,9 +256,14 @@ export function ChatRuntime({ children }: { children: ReactNode }) {
*/
function NewChatContent() {
const location = useLocation();
const agentId = new URLSearchParams(location.search).get('agent');
const agentId = new URLSearchParams(location.search).get("agent");
if (agentId) {
return <AgentChatPage key={`${agentId}:${location.key}`} embeddedAgentId={agentId} />;
return (
<AgentChatPage
key={`${agentId}:${location.key}`}
embeddedAgentId={agentId}
/>
);
}
return <ChatPage />;
}
@@ -393,8 +410,8 @@ function AgentAwareChatThreadContent() {
};
// Task-bound threads need their task host to preserve the purpose gate,
// action partitioning, and other task-specific controls.
const isTaskThread = typeof record.metadata?.taskId === 'string';
setAgentId(isTaskThread ? null : agentRefOfThread(record) ?? null);
const isTaskThread = typeof record.metadata?.taskId === "string";
setAgentId(isTaskThread ? null : (agentRefOfThread(record) ?? null));
})
.catch(() => {
if (!cancelled) setAgentId(null);
@@ -537,11 +554,18 @@ export default function WorkspaceRoutes({
/>
<Route
path="chats_iframe/new"
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam={false} />}
element={
<IframeChatEntryRoute
layoutProps={layoutProps}
hasThreadParam={false}
/>
}
/>
<Route
path="chats_iframe/:thread_id"
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam />}
element={
<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam />
}
/>
<Route
path="agents"
@@ -575,7 +599,10 @@ export default function WorkspaceRoutes({
path="agents/sentiment-analysis/chats"
element={
isSentimentAgentVisible() ? (
<Navigate to="/page/workspace/agents/sentiment-analysis/chats/new" replace />
<Navigate
to="/page/workspace/agents/sentiment-analysis/chats/new"
replace
/>
) : (
<Navigate to="/page/workspace/agents" replace />
)
@@ -605,7 +632,9 @@ export default function WorkspaceRoutes({
<AgentChatPage
showAgentList
compactAppearanceActions={
headerHidden && !compactActionsHidden ? <CompactAppearanceActions /> : undefined
headerHidden && !compactActionsHidden ? (
<CompactAppearanceActions />
) : undefined
}
/>
</ChatRuntime>
@@ -704,7 +733,9 @@ export default function WorkspaceRoutes({
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<RoundtablePlanningPage compactChrome={headerHidden && !compactActionsHidden} />
<RoundtablePlanningPage
compactChrome={headerHidden && !compactActionsHidden}
/>
</ChatRuntime>
</WorkspaceLayout>
}
@@ -714,7 +745,9 @@ export default function WorkspaceRoutes({
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<EightBfRoundtablePage compactChrome={headerHidden && !compactActionsHidden} />
<EightBfRoundtablePage
compactChrome={headerHidden && !compactActionsHidden}
/>
</ChatRuntime>
</WorkspaceLayout>
}
@@ -724,7 +757,9 @@ export default function WorkspaceRoutes({
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<PositionRoundtablePage compactChrome={headerHidden && !compactActionsHidden} />
<PositionRoundtablePage
compactChrome={headerHidden && !compactActionsHidden}
/>
</ChatRuntime>
</WorkspaceLayout>
}
@@ -794,6 +829,14 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="knowledge/:noteId/packages"
element={
<WorkspaceLayout {...layoutProps}>
<WeKnoraPackageTransfersPage />
</WorkspaceLayout>
}
/>
<Route
path="knowledge/markdown-editor"
element={
@@ -818,7 +861,9 @@ export default function WorkspaceRoutes({
path="scheduled/tasks"
element={
<WorkspaceLayout {...layoutProps}>
<ScheduledTasksPage compactChrome={headerHidden && !compactActionsHidden} />
<ScheduledTasksPage
compactChrome={headerHidden && !compactActionsHidden}
/>
</WorkspaceLayout>
}
/>
@@ -834,7 +879,7 @@ export default function WorkspaceRoutes({
<Route
path="admin"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps} pageSidebarHidden>
<AdminUserHubPage />
</WorkspaceLayout>
@@ -859,7 +904,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/scheduled-tasks"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<AdminScheduledTasksPage />
</WorkspaceLayout>
@@ -871,7 +916,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/agents"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<AdminAgentsPage />
</WorkspaceLayout>
@@ -883,7 +928,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/skills"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<AdminSkillsPage />
</WorkspaceLayout>
@@ -903,7 +948,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/plugins"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PluginManagementPage />
</WorkspaceLayout>
@@ -915,7 +960,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/positions"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PositionsPage />
</WorkspaceLayout>
@@ -927,7 +972,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/position-roles"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PositionRolesPage />
</WorkspaceLayout>
@@ -939,7 +984,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/recommended-questions"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<RecommendedQuestionsPage />
</WorkspaceLayout>
@@ -951,7 +996,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/prompt-prefix"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PromptPrefixSettingsPage />
</WorkspaceLayout>
@@ -963,7 +1008,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/private-square"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PrivateSquareSettingsPage />
</WorkspaceLayout>
@@ -975,7 +1020,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/models"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<ModelManagementPage />
</WorkspaceLayout>
@@ -987,7 +1032,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/model-concurrency"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<ModelConcurrencySettingsPage />
</WorkspaceLayout>
@@ -999,7 +1044,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/publish-squares"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PublishSquaresSettingsPage />
</WorkspaceLayout>
@@ -1011,7 +1056,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/skill-compression"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<SkillCompressionSettingsPage />
</WorkspaceLayout>
@@ -1023,7 +1068,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<LlmMetricsPage />
</WorkspaceLayout>
@@ -1035,7 +1080,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/checkpoint-migration"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<CheckpointMigrationPage />
</WorkspaceLayout>
@@ -39,6 +39,17 @@ export interface AssistantWikiPage {
out_links?: string[];
source_refs?: string[];
origins?: { mapping_id?: string; source_name: string; slug: string }[];
matched_content?: string;
matched_section?: {
id?: string;
section_index: number;
heading?: string | null;
content: string;
score: number;
source_chunk_id?: string | null;
embedding_model?: string | null;
embedding_fingerprint?: string | null;
};
}
export interface AssistantWikiRevision {
@@ -177,6 +188,32 @@ export async function createAssistantKnowledgeBase(input: {
return data.base;
}
export async function updateAssistantKnowledgeBase(input: {
id: string;
name: string;
description?: string;
}) {
const data = await readJson<{ base: AssistantKnowledgeBase }>(
await apiFetch(`${BASE()}/bases/${encodeURIComponent(input.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: input.name,
description: input.description || "",
}),
}),
);
return data.base;
}
export async function deleteAssistantKnowledgeBase(id: string) {
const response = await apiFetch(
`${BASE()}/bases/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
if (!response.ok) await readJson(response);
}
function withBaseParam(baseId?: string | null) {
const params = new URLSearchParams();
if (baseId) params.set("base_id", baseId);
@@ -441,6 +478,14 @@ export function useAssistantKnowledgeActions() {
mutationFn: createAssistantKnowledgeBase,
onSuccess: invalidate,
}),
updateBase: useMutation({
mutationFn: updateAssistantKnowledgeBase,
onSuccess: invalidate,
}),
deleteBase: useMutation({
mutationFn: deleteAssistantKnowledgeBase,
onSuccess: invalidate,
}),
uploadWeKnoraPackage: useMutation({
mutationFn: uploadAssistantWeKnoraPackage,
onSuccess: invalidate,
@@ -1,6 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getCsrfToken } from "@/core/api/csrf";
import { getAuthorizationHeaderValue } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { getBackendBaseURL } from "@/core/config";
@@ -220,7 +222,14 @@ export interface LlmWikiWikiIndex {
export interface LlmWikiWikiIndexStatus {
knowledge_base_id: string;
state: "not_synced" | "idle" | "running" | "partial" | "failed" | "rebuilding" | "rebuild_required";
state:
| "not_synced"
| "idle"
| "running"
| "partial"
| "failed"
| "rebuilding"
| "rebuild_required";
local_index_enabled: boolean;
wiki_index_enabled: boolean;
fully_vectorized: boolean;
@@ -256,6 +265,25 @@ export interface LlmWikiWikiIndexStatus {
}>;
}
export interface LocalWikiEncoderStatus {
provider: string;
model: string;
dimensions?: number | null;
state: "idle" | "starting" | "ready" | "failed";
running: boolean;
last_error?: string | null;
started_at?: string | null;
ready_at?: string | null;
offline_only: boolean;
model_path_configured: boolean;
model_path_exists: boolean;
model_files_ready: boolean;
process_isolation: boolean;
model_path: string;
uploaded_files?: number;
uploaded_bytes?: number;
}
export interface LlmWikiSourceContext {
knowledge_base_name?: string;
document_title?: string;
@@ -345,14 +373,16 @@ function userFacingKnowledgeMessage(value: string): string {
async function readJson<T>(response: Response, context: string): Promise<T> {
if (!response.ok) {
const payload = await response.json().catch(() => null as unknown);
const rawDetail = payload && typeof payload === "object" && "detail" in payload
? (payload as { detail: unknown }).detail
: null;
const detail = rawDetail && typeof rawDetail === "object" && "message" in rawDetail
? String((rawDetail as { message: unknown }).message)
: rawDetail
? String(rawDetail)
: `${context}失败(${response.status})`;
const rawDetail =
payload && typeof payload === "object" && "detail" in payload
? (payload as { detail: unknown }).detail
: null;
const detail =
rawDetail && typeof rawDetail === "object" && "message" in rawDetail
? String((rawDetail as { message: unknown }).message)
: rawDetail
? String(rawDetail)
: `${context}失败(${response.status})`;
throw new Error(userFacingKnowledgeMessage(detail));
}
if (response.status === 204) return undefined as T;
@@ -360,10 +390,7 @@ async function readJson<T>(response: Response, context: string): Promise<T> {
}
export async function getLlmWikiRuntime(): Promise<LlmWikiRuntime> {
return readJson(
await apiFetch(`${API()}/runtime`),
"读取 LLMWiki 运行模式",
);
return readJson(await apiFetch(`${API()}/runtime`), "读取 LLMWiki 运行模式");
}
export async function listLlmWikiKnowledgeBases(
@@ -421,10 +448,10 @@ export async function getLlmWikiSourceContext(
knowledge_base_id: knowledgeBaseId,
chunk_id: chunkId,
});
const payload = await readJson<{ success: boolean; data: LlmWikiSourceContext }>(
await apiFetch(`${API()}/sources/context?${params}`),
"加载参考文献原文",
);
const payload = await readJson<{
success: boolean;
data: LlmWikiSourceContext;
}>(await apiFetch(`${API()}/sources/context?${params}`), "加载参考文献原文");
return payload.data;
}
@@ -537,9 +564,13 @@ export async function getLlmWikiProtectedFileUrl(
return `${PUBLIC_API()}/files/${encodeURIComponent(access.token)}`;
}
export async function getLlmWikiGraph(knowledgeBaseId: string): Promise<LlmWikiGraph> {
export async function getLlmWikiGraph(
knowledgeBaseId: string,
): Promise<LlmWikiGraph> {
return readJson(
await apiFetch(`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/graph?limit=200`),
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/graph?limit=200`,
),
"读取知识图谱",
);
}
@@ -550,7 +581,10 @@ export async function listLlmWikiWikiPages(
pageSize: number,
query = "",
): Promise<LlmWikiWikiPageList> {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
const params = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
});
if (query.trim()) params.set("query", query.trim());
return readJson(
await apiFetch(
@@ -596,6 +630,80 @@ export async function vectorizeLlmWikiKnowledgeBase(
);
}
export async function getLocalWikiEncoderStatus(): Promise<LocalWikiEncoderStatus> {
return readJson(
await apiFetch(`${API()}/local-encoder/status`),
"读取本地编码器状态",
);
}
export async function startLocalWikiEncoder(): Promise<LocalWikiEncoderStatus> {
return readJson(
await apiFetch(`${API()}/local-encoder/start`, { method: "POST" }),
"启动本地编码器",
);
}
export async function configureLocalWikiEncoder(
modelPath: string,
): Promise<LocalWikiEncoderStatus> {
return readJson(
await apiFetch(`${API()}/local-encoder/config`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_path: modelPath }),
}),
"保存本地编码器目录",
);
}
export async function uploadLocalWikiEncoder(
files: File[],
onProgress?: (percent: number) => void,
): Promise<LocalWikiEncoderStatus> {
const body = new FormData();
for (const file of files) {
const webkitPath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
const parts = webkitPath.replace(/\\/g, "/").split("/").filter(Boolean);
const relativePath = parts.length > 1 ? parts.slice(1).join("/") : parts[0];
body.append("files", file, file.name);
body.append("paths", relativePath);
}
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("POST", `${API()}/local-encoder/upload`);
request.withCredentials = true;
const authorization = getAuthorizationHeaderValue();
const csrf = getCsrfToken();
if (authorization) request.setRequestHeader("Authorization", authorization);
if (csrf) request.setRequestHeader("X-CSRF-Token", csrf);
request.upload.onprogress = (event) => {
if (event.lengthComputable && event.total > 0) {
onProgress?.(Math.min(99, Math.round((event.loaded / event.total) * 100)));
}
};
request.onerror = () => reject(new Error("上传本地编码器失败:网络连接中断"));
request.onload = () => {
let payload: unknown = null;
try {
payload = JSON.parse(request.responseText || "null");
} catch {
payload = null;
}
if (request.status < 200 || request.status >= 300) {
const detail = payload && typeof payload === "object" && "detail" in payload
? (payload as { detail: unknown }).detail
: `请求失败 (${request.status})`;
reject(new Error(typeof detail === "string" ? detail : JSON.stringify(detail)));
return;
}
onProgress?.(100);
resolve(payload as LocalWikiEncoderStatus);
};
request.send(body);
});
}
export async function exportLlmWikiWikiExcel(
knowledgeBaseId: string,
knowledgeBaseName = "LLMWiki",
@@ -808,7 +916,10 @@ export async function listPublicLlmWikiWikiPages(
pageSize: number,
query = "",
): Promise<LlmWikiWikiPageList> {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
const params = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
});
if (query.trim()) params.set("query", query.trim());
return readJson(
await apiFetch(
@@ -887,9 +998,7 @@ export async function resetLlmWikiSettings(): Promise<LlmWikiRuntime> {
);
}
export async function testLlmWikiConnection(
apiBaseUrl: string,
): Promise<void> {
export async function testLlmWikiConnection(apiBaseUrl: string): Promise<void> {
await readJson(
await apiFetch(`${SETTINGS_API()}/test`, {
method: "POST",
@@ -914,10 +1023,7 @@ export function useLlmWikiRuntime() {
});
}
export function useLlmWikiKnowledgeBases(
scope: LlmWikiScope,
enabled = true,
) {
export function useLlmWikiKnowledgeBases(scope: LlmWikiScope, enabled = true) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: knowledgeBasesKey(userId, scope),
@@ -933,7 +1039,13 @@ export function useAgentLlmWikiKnowledgeBases(
) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "agents", agentId, "knowledge-bases", userId ?? "anonymous"],
queryKey: [
"llmwiki",
"agents",
agentId,
"knowledge-bases",
userId ?? "anonymous",
],
queryFn: () => listAgentLlmWikiKnowledgeBases(agentId!),
enabled: enabled && !!agentId,
});
@@ -945,7 +1057,12 @@ export function useLlmWikiKnowledgeBase(
) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "knowledge-base", knowledgeBaseId, userId ?? "anonymous"],
queryKey: [
"llmwiki",
"knowledge-base",
knowledgeBaseId,
userId ?? "anonymous",
],
queryFn: () => getLlmWikiKnowledgeBase(knowledgeBaseId!),
enabled: enabled && !!knowledgeBaseId,
refetchInterval: 8_000,
@@ -972,7 +1089,13 @@ export function useLlmWikiDocument(
) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "document", knowledgeBaseId, documentId, userId ?? "anonymous"],
queryKey: [
"llmwiki",
"document",
knowledgeBaseId,
documentId,
userId ?? "anonymous",
],
queryFn: () => getLlmWikiDocument(knowledgeBaseId!, documentId!),
enabled: enabled && !!knowledgeBaseId && !!documentId,
});
@@ -987,13 +1110,25 @@ export function useLlmWikiDocumentChunks(
) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "document-chunks", knowledgeBaseId, documentId, page, pageSize, userId ?? "anonymous"],
queryFn: () => listLlmWikiDocumentChunks(knowledgeBaseId!, documentId!, page, pageSize),
queryKey: [
"llmwiki",
"document-chunks",
knowledgeBaseId,
documentId,
page,
pageSize,
userId ?? "anonymous",
],
queryFn: () =>
listLlmWikiDocumentChunks(knowledgeBaseId!, documentId!, page, pageSize),
enabled: enabled && !!knowledgeBaseId && !!documentId,
});
}
export function useLlmWikiGraph(knowledgeBaseId: string | null, enabled = true) {
export function useLlmWikiGraph(
knowledgeBaseId: string | null,
enabled = true,
) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "graph", knowledgeBaseId, userId ?? "anonymous"],
@@ -1011,8 +1146,17 @@ export function useLlmWikiWikiPages(
) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "wiki-pages", knowledgeBaseId, page, pageSize, query, userId ?? "anonymous"],
queryFn: () => listLlmWikiWikiPages(knowledgeBaseId!, page, pageSize, query),
queryKey: [
"llmwiki",
"wiki-pages",
knowledgeBaseId,
page,
pageSize,
query,
userId ?? "anonymous",
],
queryFn: () =>
listLlmWikiWikiPages(knowledgeBaseId!, page, pageSize, query),
enabled: enabled && !!knowledgeBaseId,
refetchInterval: 10_000,
});
@@ -1025,7 +1169,13 @@ export function useLlmWikiWikiPage(
) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "wiki-page", knowledgeBaseId, slug, userId ?? "anonymous"],
queryKey: [
"llmwiki",
"wiki-page",
knowledgeBaseId,
slug,
userId ?? "anonymous",
],
queryFn: () => getLlmWikiWikiPage(knowledgeBaseId!, slug!),
enabled: enabled && !!knowledgeBaseId && !!slug,
});
@@ -1051,8 +1201,16 @@ export function usePublicLlmWikiWikiPages(
enabled = true,
) {
return useQuery({
queryKey: ["public-llmwiki", "wiki-pages", knowledgeBaseId, page, pageSize, query],
queryFn: () => listPublicLlmWikiWikiPages(knowledgeBaseId!, page, pageSize, query),
queryKey: [
"public-llmwiki",
"wiki-pages",
knowledgeBaseId,
page,
pageSize,
query,
],
queryFn: () =>
listPublicLlmWikiWikiPages(knowledgeBaseId!, page, pageSize, query),
enabled: enabled && !!knowledgeBaseId,
staleTime: 60_000,
});
@@ -1089,7 +1247,12 @@ export function useLlmWikiWikiIndexStatus(
) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "wiki-index-status", knowledgeBaseId, userId ?? "anonymous"],
queryKey: [
"llmwiki",
"wiki-index-status",
knowledgeBaseId,
userId ?? "anonymous",
],
queryFn: () => getLlmWikiWikiIndexStatus(knowledgeBaseId!),
enabled: enabled && !!knowledgeBaseId,
refetchInterval: (query) => {
@@ -1118,8 +1281,14 @@ export function useLlmWikiActions() {
onSuccess: invalidateKnowledge,
}),
update: useMutation({
mutationFn: ({ id, ...input }: { id: string; name?: string; description?: string }) =>
updateLlmWikiKnowledgeBase(id, input),
mutationFn: ({
id,
...input
}: {
id: string;
name?: string;
description?: string;
}) => updateLlmWikiKnowledgeBase(id, input),
onSuccess: invalidateKnowledge,
}),
remove: useMutation({
@@ -1137,8 +1306,15 @@ export function useLlmWikiActions() {
onSuccess: invalidateKnowledge,
}),
createManual: useMutation({
mutationFn: ({ id, title, content }: { id: string; title: string; content: string }) =>
createManualLlmWikiDocument(id, { title, content }),
mutationFn: ({
id,
title,
content,
}: {
id: string;
title: string;
content: string;
}) => createManualLlmWikiDocument(id, { title, content }),
onSuccess: invalidateKnowledge,
}),
removeDocument: useMutation({
@@ -1152,13 +1328,25 @@ export function useLlmWikiActions() {
onSuccess: invalidateKnowledge,
}),
createWikiPage: useMutation({
mutationFn: ({ id, input }: { id: string; input: LlmWikiWikiPageCreate }) =>
createLlmWikiWikiPage(id, input),
mutationFn: ({
id,
input,
}: {
id: string;
input: LlmWikiWikiPageCreate;
}) => createLlmWikiWikiPage(id, input),
onSuccess: invalidateKnowledge,
}),
updateWikiPage: useMutation({
mutationFn: ({ id, slug, input }: { id: string; slug: string; input: LlmWikiWikiPageUpdate }) =>
updateLlmWikiWikiPage(id, slug, input),
mutationFn: ({
id,
slug,
input,
}: {
id: string;
slug: string;
input: LlmWikiWikiPageUpdate;
}) => updateLlmWikiWikiPage(id, slug, input),
onSuccess: invalidateKnowledge,
}),
removeWikiPage: useMutation({
@@ -1188,8 +1376,14 @@ export function useLlmWikiSettingsActions() {
await queryClient.invalidateQueries({ queryKey: ["llmwiki"] });
};
return {
save: useMutation({ mutationFn: saveLlmWikiSettings, onSuccess: invalidate }),
reset: useMutation({ mutationFn: resetLlmWikiSettings, onSuccess: invalidate }),
save: useMutation({
mutationFn: saveLlmWikiSettings,
onSuccess: invalidate,
}),
reset: useMutation({
mutationFn: resetLlmWikiSettings,
onSuccess: invalidate,
}),
test: useMutation({ mutationFn: testLlmWikiConnection }),
};
}
@@ -109,9 +109,11 @@ async function readJson<T>(response: Response): Promise<T> {
export async function listSkillKnowledgeBindings(
skillNames?: string[],
mineOnly = false,
): Promise<SkillKnowledgeBinding[]> {
const params = new URLSearchParams();
for (const name of skillNames ?? []) params.append("skill_names", name);
if (mineOnly) params.set("mine_only", "true");
const response = await apiFetch(`${BASE()}/bindings?${params.toString()}`);
return (await readJson<{ bindings: SkillKnowledgeBinding[] }>(response)).bindings;
}
@@ -131,8 +133,9 @@ export async function createSkillKnowledgeJob(input: {
);
}
export async function listSkillKnowledgeJobs(): Promise<SkillKnowledgeJob[]> {
const response = await apiFetch(`${BASE()}/sync-jobs`);
export async function listSkillKnowledgeJobs(mineOnly = false): Promise<SkillKnowledgeJob[]> {
const params = mineOnly ? "?mine_only=true" : "";
const response = await apiFetch(`${BASE()}/sync-jobs${params}`);
return (await readJson<{ jobs: SkillKnowledgeJob[] }>(response)).jobs;
}
@@ -221,18 +224,18 @@ export async function updateSkillKnowledgeAutoSyncSettings(
const KEY = "skill-knowledge";
export function useSkillKnowledgeBindings(skillNames?: string[]) {
export function useSkillKnowledgeBindings(skillNames?: string[], mineOnly = false) {
return useQuery({
queryKey: [KEY, "bindings", ...(skillNames ?? [])],
queryFn: () => listSkillKnowledgeBindings(skillNames),
queryKey: [KEY, "bindings", mineOnly ? "mine" : "all", ...(skillNames ?? [])],
queryFn: () => listSkillKnowledgeBindings(skillNames, mineOnly),
refetchOnWindowFocus: false,
});
}
export function useSkillKnowledgeJobs() {
export function useSkillKnowledgeJobs(mineOnly = false) {
return useQuery({
queryKey: [KEY, "jobs"],
queryFn: listSkillKnowledgeJobs,
queryKey: [KEY, "jobs", mineOnly ? "mine" : "all"],
queryFn: () => listSkillKnowledgeJobs(mineOnly),
refetchInterval: (query) =>
(query.state.data ?? []).some((job) => ["queued", "running"].includes(job.status))
? 1500
@@ -1,15 +1,31 @@
import { AlertCircle, Download, Edit3, FileText, Loader2, RefreshCw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
AlertCircle,
Archive,
Edit3,
FileText,
Loader2,
RefreshCw,
Trash2,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
Navigate,
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownCodeEditor } from "@/components/wiki/MarkdownCodeEditor";
import { WikiPackageTransfers } from "./WikiPackageTransfers";
import { getBackendBaseURL } from "@/core/config";
import { getStoredAuth } from "@/core/auth";
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
@@ -21,20 +37,8 @@ import {
useLlmWikiWikiPage,
useLlmWikiRuntime,
} from "@/strategy-components/api/llmwiki";
import {
createLlmWikiExportPackage,
listLlmWikiExportPackages,
type LlmWikiExportPackageJob,
} from "@/strategy-components/api/assistant-knowledge";
const LIST_PATH = "/page/workspace/knowledge";
const RUNNING_EXPORT_STATUSES = new Set(["queued", "running"]);
const EXPORT_STATUS_LABELS: Record<string, string> = {
queued: "排队中",
running: "整理中",
completed: "已完成",
failed: "失败",
};
type DeerFlowThemePayload = {
type: "deerflow-weknora-theme";
@@ -44,7 +48,10 @@ type DeerFlowThemePayload = {
function cssVar(name: string, fallback = ""): string {
if (typeof window === "undefined") return fallback;
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
return (
getComputedStyle(document.documentElement).getPropertyValue(name).trim() ||
fallback
);
}
function collectThemePayload(): DeerFlowThemePayload {
@@ -56,15 +63,27 @@ function collectThemePayload(): DeerFlowThemePayload {
theme: isDark ? "dark" : "light",
vars: {
primary: cssVar("--primary", isDark ? "#2b81ff" : "#0062d9"),
primaryForeground: cssVar("--button-foreground", cssVar("--primary-foreground", "#ffffff")),
primaryForeground: cssVar(
"--button-foreground",
cssVar("--primary-foreground", "#ffffff"),
),
background: cssVar("--background", isDark ? "#0e172b" : "#ffffff"),
foreground: cssVar("--foreground", isDark ? "#eff2f5" : "#202a3a"),
card: cssVar("--card", isDark ? "#1a2539" : "#ffffff"),
cardForeground: cssVar("--card-foreground", cssVar("--foreground", isDark ? "#eff2f5" : "#202a3a")),
cardForeground: cssVar(
"--card-foreground",
cssVar("--foreground", isDark ? "#eff2f5" : "#202a3a"),
),
muted: cssVar("--muted", isDark ? "#232e40" : "#f3f5f8"),
mutedForeground: cssVar("--muted-foreground", isDark ? "#8e9aa4" : "#7f7f7f"),
mutedForeground: cssVar(
"--muted-foreground",
isDark ? "#8e9aa4" : "#7f7f7f",
),
border: cssVar("--border", isDark ? "rgba(255,255,255,0.12)" : "#dbdbdb"),
popover: cssVar("--popover", cssVar("--card", isDark ? "#1a2539" : "#ffffff")),
popover: cssVar(
"--popover",
cssVar("--card", isDark ? "#1a2539" : "#ffffff"),
),
destructive: cssVar("--destructive", "#d54941"),
},
};
@@ -78,7 +97,8 @@ function postThemeToFrame(frame: HTMLIFrameElement | null) {
try {
const currentUrl = frame.contentWindow.location.href;
if (!currentUrl || currentUrl === "about:blank") return;
if (new URL(currentUrl, window.location.href).origin !== targetOrigin) return;
if (new URL(currentUrl, window.location.href).origin !== targetOrigin)
return;
} catch {
// Reading location throws once the iframe has reached the cross-origin backend.
}
@@ -99,26 +119,9 @@ function buildFrameUrl(framePath: string) {
: framePath;
return new URL(path, backendOrigin);
}
return new URL(`${backendBase || backendOrigin}/${framePath.replace(/^\/+/, "")}`);
}
function formatBytes(value: number | undefined | null) {
const bytes = Number(value || 0);
if (!Number.isFinite(bytes) || bytes <= 0) return "—";
const units = ["B", "KB", "MB", "GB", "TB"];
let current = bytes;
let unitIndex = 0;
while (current >= 1024 && unitIndex < units.length - 1) {
current /= 1024;
unitIndex += 1;
}
return `${current >= 10 || unitIndex === 0 ? current.toFixed(0) : current.toFixed(1)} ${units[unitIndex]}`;
}
function formatTime(value: string | undefined | null) {
if (!value) return "—";
const time = new Date(value);
return Number.isNaN(time.getTime()) ? "—" : time.toLocaleString();
return new URL(
`${backendBase || backendOrigin}/${framePath.replace(/^\/+/, "")}`,
);
}
export function WeKnoraKnowledgeDetailPage() {
@@ -126,33 +129,19 @@ export function WeKnoraKnowledgeDetailPage() {
knowledge_base_id: strategyKnowledgeBaseId,
noteId: workspaceKnowledgeBaseId,
} = useParams();
const knowledgeBaseId = strategyKnowledgeBaseId ?? workspaceKnowledgeBaseId ?? "";
const knowledgeBaseId =
strategyKnowledgeBaseId ?? workspaceKnowledgeBaseId ?? "";
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const frameRef = useRef<HTMLIFrameElement>(null);
const [frameSrc, setFrameSrc] = useState("");
const [frameVersion, setFrameVersion] = useState(0);
const [frameError, setFrameError] = useState<string | null>(null);
const [exportPackageLoading, setExportPackageLoading] = useState(false);
const [exportJobsLoading, setExportJobsLoading] = useState(false);
const [exportJobs, setExportJobs] = useState<LlmWikiExportPackageJob[]>([]);
const isAdmin = getStoredAuth()?.system_role === "admin";
const runtime = useLlmWikiRuntime();
const details = useLlmWikiKnowledgeBase(knowledgeBaseId || null, !!knowledgeBaseId);
const refreshExportJobs = useCallback(
async (silent = false) => {
if (!knowledgeBaseId || !isAdmin) return;
if (!silent) setExportJobsLoading(true);
try {
setExportJobs(await listLlmWikiExportPackages(knowledgeBaseId));
} catch (error) {
if (!silent) toast.error(error instanceof Error ? error.message : String(error));
} finally {
if (!silent) setExportJobsLoading(false);
}
},
[isAdmin, knowledgeBaseId],
const details = useLlmWikiKnowledgeBase(
knowledgeBaseId || null,
!!knowledgeBaseId,
);
const detailsReady = !!details.data;
@@ -162,25 +151,35 @@ export function WeKnoraKnowledgeDetailPage() {
details.data?.wiki_index_enabled === true ||
details.data?.indexing_strategy?.wiki_enabled === true ||
details.data?.capabilities?.wiki_enabled === true ||
(details.data?.wiki_config && Object.keys(details.data.wiki_config).length > 0);
(details.data?.wiki_config &&
Object.keys(details.data.wiki_config).length > 0);
const preferredTab =
requestedTab === "documents" || requestedTab === "wiki" || requestedTab === "graph"
requestedTab === "documents" ||
requestedTab === "wiki" ||
requestedTab === "graph"
? requestedTab
: detailsWikiEnabled
? "wiki"
: "documents";
const sourceChunkId = searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
const sourceChunkId =
searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
const documentId = searchParams.get("documentId") || "";
const wikiSlug = searchParams.get("wikiSlug") || "";
const nativeWikiPageUrl = knowledgeBaseId && wikiSlug && preferredTab === "wiki"
? buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)
: "";
const nativeWikiPageUrl =
knowledgeBaseId && wikiSlug && preferredTab === "wiki"
? buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)
: "";
useEffect(() => {
let cancelled = false;
setFrameSrc("");
setFrameError(null);
if (nativeWikiPageUrl || !knowledgeBaseId || runtime.data?.provider !== "weknora" || !detailsReady) {
if (
nativeWikiPageUrl ||
!knowledgeBaseId ||
runtime.data?.provider !== "weknora" ||
!detailsReady
) {
return () => {
cancelled = true;
};
@@ -190,30 +189,47 @@ export function WeKnoraKnowledgeDetailPage() {
const resolvedWikiSlug =
wikiSlug ||
(sourceChunkId
? (await getLlmWikiSourceContext(knowledgeBaseId, sourceChunkId).catch(
() => null,
))?.wiki_page?.slug
? (
await getLlmWikiSourceContext(
knowledgeBaseId,
sourceChunkId,
).catch(() => null)
)?.wiki_page?.slug
: "");
const session = await createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab);
if (cancelled) return;
const framePath = session.frame_path || "";
const nextUrl = buildFrameUrl(framePath);
nextUrl.searchParams.set("theme", collectThemePayload().theme);
if (sourceChunkId) nextUrl.searchParams.set("sourceChunkId", sourceChunkId);
if (documentId) nextUrl.searchParams.set("documentId", documentId);
if (resolvedWikiSlug) nextUrl.searchParams.set("wikiSlug", resolvedWikiSlug);
setFrameSrc(nextUrl.toString());
})()
.catch((error: unknown) => {
if (cancelled) return;
const message = error instanceof Error ? error.message : "创建知识库详情会话失败";
setFrameError(message);
});
const session = await createLlmWikiWeKnoraEmbedSession(
knowledgeBaseId,
preferredTab,
);
if (cancelled) return;
const framePath = session.frame_path || "";
const nextUrl = buildFrameUrl(framePath);
nextUrl.searchParams.set("theme", collectThemePayload().theme);
if (sourceChunkId)
nextUrl.searchParams.set("sourceChunkId", sourceChunkId);
if (documentId) nextUrl.searchParams.set("documentId", documentId);
if (resolvedWikiSlug)
nextUrl.searchParams.set("wikiSlug", resolvedWikiSlug);
setFrameSrc(nextUrl.toString());
})().catch((error: unknown) => {
if (cancelled) return;
const message =
error instanceof Error ? error.message : "创建知识库详情会话失败";
setFrameError(message);
});
return () => {
cancelled = true;
};
}, [detailsReady, documentId, knowledgeBaseId, nativeWikiPageUrl, preferredTab, runtime.data?.provider, sourceChunkId, wikiSlug]);
}, [
detailsReady,
documentId,
knowledgeBaseId,
nativeWikiPageUrl,
preferredTab,
runtime.data?.provider,
sourceChunkId,
wikiSlug,
]);
useEffect(() => {
if (!frameSrc) return;
@@ -233,22 +249,6 @@ export function WeKnoraKnowledgeDetailPage() {
};
}, [frameSrc]);
useEffect(() => {
if (!isAdmin || !knowledgeBaseId || runtime.data?.provider !== "weknora") {
setExportJobs([]);
return;
}
void refreshExportJobs();
}, [isAdmin, knowledgeBaseId, refreshExportJobs, runtime.data?.provider]);
useEffect(() => {
if (!isAdmin || !knowledgeBaseId || !exportJobs.some((job) => RUNNING_EXPORT_STATUSES.has(job.status))) {
return;
}
const timer = window.setInterval(() => void refreshExportJobs(true), 5000);
return () => window.clearInterval(timer);
}, [exportJobs, isAdmin, knowledgeBaseId, refreshExportJobs]);
if (nativeWikiPageUrl) {
return <Navigate to={nativeWikiPageUrl} replace />;
}
@@ -262,7 +262,8 @@ export function WeKnoraKnowledgeDetailPage() {
);
}
if (runtime.data?.provider !== "weknora") return <Navigate to={LIST_PATH} replace />;
if (runtime.data?.provider !== "weknora")
return <Navigate to={LIST_PATH} replace />;
if (frameError) {
return (
@@ -271,7 +272,9 @@ export function WeKnoraKnowledgeDetailPage() {
<AlertCircle className="size-6" />
</div>
<div>
<div className="text-base font-semibold text-foreground">无法打开知识库详情</div>
<div className="text-base font-semibold text-foreground">
无法打开知识库详情
</div>
<p className="mt-2 text-sm text-muted-foreground">{frameError}</p>
</div>
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>
@@ -288,7 +291,9 @@ export function WeKnoraKnowledgeDetailPage() {
<AlertCircle className="size-6" />
</div>
<div>
<div className="text-base font-semibold text-foreground">无法打开知识库详情</div>
<div className="text-base font-semibold text-foreground">
无法打开知识库详情
</div>
<p className="mt-2 text-sm text-muted-foreground">
当前账号没有访问权限,或知识服务资源已不存在。
</p>
@@ -300,31 +305,14 @@ export function WeKnoraKnowledgeDetailPage() {
);
}
const canWrite = details.data.can_write && !details.data.is_conversation_deposit;
const canWrite =
details.data.can_write && !details.data.is_conversation_deposit;
const canEditCurrentWikiArticle =
canWrite && !!detailsWikiEnabled && !!wikiSlug;
const canManageWiki = canWrite && !!detailsWikiEnabled;
const createVectorizedPackage = () => {
if (exportPackageLoading) return;
setExportPackageLoading(true);
void (async () => {
try {
const job = await createLlmWikiExportPackage(knowledgeBaseId);
setExportJobs((current) => [job, ...current.filter((item) => item.id !== job.id)]);
toast.success("已创建向量化数据包整理记录,完成后可在下方列表下载");
void refreshExportJobs(true);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setExportPackageLoading(false);
}
})();
};
return (
<div className="flex h-full min-h-0 flex-col bg-background">
{canManageWiki && isAdmin && <details className="shrink-0 border-b px-4 py-2"><summary className="cursor-pointer text-sm">从助手知识库导入数据</summary><div className="py-3"><WikiPackageTransfers id={knowledgeBaseId} /></div></details>}
{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="min-w-0 flex-1">
@@ -334,9 +322,12 @@ export function WeKnoraKnowledgeDetailPage() {
slug={wikiSlug}
onChanged={() => setFrameVersion((value) => value + 1)}
onDeleted={() => {
navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}?tab=wiki`, {
replace: true,
});
navigate(
`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}?tab=wiki`,
{
replace: true,
},
);
setFrameVersion((value) => value + 1);
}}
/>
@@ -347,17 +338,24 @@ export function WeKnoraKnowledgeDetailPage() {
<Button
size="sm"
variant="outline"
disabled={exportPackageLoading}
onClick={createVectorizedPackage}
onClick={() =>
navigate(
`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/packages`,
)
}
>
{exportPackageLoading ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Download className="mr-2 size-4" />}
生成向量化数据包
<Archive className="mr-2 size-4" />
数据包任务
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`)}
onClick={() =>
navigate(
`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`,
)
}
>
<FileText className="mr-2 size-4" />
Wiki管理
@@ -365,13 +363,6 @@ export function WeKnoraKnowledgeDetailPage() {
</div>
</div>
)}
{canManageWiki && isAdmin && (
<ExportPackageJobsPanel
jobs={exportJobs}
loading={exportJobsLoading}
onRefresh={() => void refreshExportJobs()}
/>
)}
{frameSrc && (
<iframe
key={`${frameSrc}:${frameVersion}`}
@@ -399,84 +390,6 @@ export function WeKnoraKnowledgeDetailPage() {
);
}
function ExportPackageJobsPanel({
jobs,
loading,
onRefresh,
}: {
jobs: LlmWikiExportPackageJob[];
loading: boolean;
onRefresh: () => void;
}) {
return (
<div className="shrink-0 border-b bg-muted/20 px-4 py-3">
<div className="mb-2 flex items-center justify-between">
<div>
<div className="text-sm font-medium">向量化数据包记录</div>
<p className="text-xs text-muted-foreground">
每次点击都会生成一条后台整理记录;文件较大时,请等待状态变为“已完成”后再下载。
</p>
</div>
<Button size="sm" variant="ghost" onClick={onRefresh} disabled={loading}>
{loading ? <Loader2 className="mr-2 size-4 animate-spin" /> : <RefreshCw className="mr-2 size-4" />}
刷新
</Button>
</div>
<div className="max-h-44 space-y-2 overflow-y-auto pr-1">
{jobs.map((job) => {
const running = RUNNING_EXPORT_STATUSES.has(job.status);
const completed = job.status === "completed";
const canDownload = completed && !!job.package_download_url && job.file_exists !== false;
const counts = job.counts ?? {};
return (
<div key={job.id} className="flex items-center gap-3 rounded-md border bg-card px-3 py-2 text-xs">
<Badge
variant={job.status === "failed" ? "destructive" : completed ? "default" : "secondary"}
className="shrink-0"
>
{running && <Loader2 className="mr-1 inline size-3 animate-spin" />}
{EXPORT_STATUS_LABELS[job.status] || job.status}
</Badge>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-medium">任务 {job.id.slice(0, 8)}</span>
<span className="text-muted-foreground">创建:{formatTime(job.created_at)}</span>
<span className="text-muted-foreground">大小:{formatBytes(job.file_size_bytes)}</span>
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-muted-foreground">
<span>Wiki {counts.wiki_page ?? counts.wiki_pages ?? 0}</span>
<span>文档 {counts.document ?? counts.documents ?? 0}</span>
<span>片段 {counts.chunk ?? counts.chunks ?? 0}</span>
<span>向量 {counts.vector ?? counts.vectors ?? 0}</span>
<span>阶段:{job.phase}</span>
{job.error && <span className="text-destructive">{job.error}</span>}
</div>
</div>
{canDownload ? (
<Button asChild size="sm" variant="outline" className="shrink-0">
<a href={job.package_download_url} target="_blank" rel="noreferrer">
<Download className="mr-2 size-4" />
下载
</a>
</Button>
) : (
<Button size="sm" variant="outline" className="shrink-0" disabled>
{completed && job.file_exists === false ? "文件不存在" : "等待完成"}
</Button>
)}
</div>
);
})}
{!jobs.length && (
<div className="rounded-md border border-dashed bg-card px-3 py-6 text-center text-xs text-muted-foreground">
暂无导出记录。点击上方“生成向量化数据包”后,任务会显示在这里。
</div>
)}
</div>
</div>
);
}
function CurrentWikiArticleActions({
knowledgeBaseId,
slug,
@@ -526,7 +439,12 @@ function CurrentWikiArticleActions({
const remove = async () => {
if (!slug) return;
if (!window.confirm(`确定删除文章「${detail.data?.title || slug}」吗?删除后不可恢复。`)) return;
if (
!window.confirm(
`确定删除文章「${detail.data?.title || slug}」吗?删除后不可恢复。`,
)
)
return;
try {
await actions.removeWikiPage.mutateAsync({ id: knowledgeBaseId, slug });
toast.success("文章已删除");
@@ -578,14 +496,18 @@ function CurrentWikiArticleActions({
<label className="text-sm font-medium">标题</label>
<Input
value={draft.title}
onChange={(event) => setDraft((prev) => ({ ...prev, title: event.target.value }))}
onChange={(event) =>
setDraft((prev) => ({ ...prev, title: event.target.value }))
}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">摘要</label>
<Textarea
value={draft.summary}
onChange={(event) => setDraft((prev) => ({ ...prev, summary: event.target.value }))}
onChange={(event) =>
setDraft((prev) => ({ ...prev, summary: event.target.value }))
}
rows={3}
/>
</div>
@@ -593,18 +515,28 @@ function CurrentWikiArticleActions({
<label className="text-sm font-medium">正文</label>
<MarkdownCodeEditor
value={draft.content}
onChange={(content) => setDraft((prev) => ({ ...prev, content }))}
onChange={(content) =>
setDraft((prev) => ({ ...prev, content }))
}
className="[&_.cm-editor]:min-h-[48vh]"
/>
</div>
</div>
<div className="mt-4 flex shrink-0 justify-end gap-2 border-t pt-3">
<Button variant="outline" onClick={() => setOpen(false)} disabled={actions.updateWikiPage.isPending}>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={actions.updateWikiPage.isPending}
>
取消
</Button>
<Button
onClick={save}
disabled={actions.updateWikiPage.isPending || !draft.title.trim() || !draft.content.trim()}
disabled={
actions.updateWikiPage.isPending ||
!draft.title.trim() ||
!draft.content.trim()
}
>
{actions.updateWikiPage.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
@@ -1,8 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
BookOpen,
BookOpenText,
ChevronRight,
Database,
Cpu,
Edit3,
FileText,
Globe2,
@@ -11,7 +13,9 @@ import {
RefreshCw,
Search,
Settings2,
Sparkles,
Trash2,
Upload,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -29,6 +33,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth";
@@ -38,7 +43,11 @@ import {
type LlmWikiSearchResponse,
type LlmWikiSearchResult,
type LlmWikiScope,
configureLocalWikiEncoder,
getLocalWikiEncoderStatus,
searchLlmWiki,
startLocalWikiEncoder,
uploadLocalWikiEncoder,
useLlmWikiActions,
useLlmWikiKnowledgeBases,
useLlmWikiSettings,
@@ -84,7 +93,6 @@ function formatSearchTime(value?: string | null) {
function resultPreview(result: LlmWikiSearchResult) {
const text = (
result.matched_sections?.map((section) => section.content).filter(Boolean).join("\n\n") ||
result.summary ||
result.content ||
""
);
@@ -172,6 +180,11 @@ export function WeKnoraKnowledgeWorkspace() {
<p className="mt-1 text-sm text-muted-foreground">由知识服务负责解析与检索,用户、权限和发布范围由 cmzs 管理。</p>
</div>
<div className="flex items-center gap-2 rounded-full border bg-background/80 p-1 shadow-sm backdrop-blur">
{tab !== "settings" && (
<Button variant="ghost" size="sm" className="rounded-full" onClick={() => navigate("/page/workspace/skills?tab=knowledge")}>
<Sparkles className="mr-2 size-4" />从技能归纳
</Button>
)}
{tab !== "settings" && (
<Button variant="ghost" size="sm" className="rounded-full" onClick={() => knowledge.refetch()} disabled={knowledge.isFetching}>
<RefreshCw className={`mr-2 size-4 ${knowledge.isFetching ? "animate-spin" : ""}`} />刷新
@@ -470,6 +483,122 @@ function CreateKnowledgeDialog({
);
}
export function LocalEncoderSettingsCard() {
const queryClient = useQueryClient();
const encoder = useQuery({
queryKey: ["llmwiki-local-encoder"],
queryFn: getLocalWikiEncoderStatus,
retry: false,
refetchInterval: (query) => query.state.data?.state === "starting" ? 1000 : false,
});
const [modelPath, setModelPath] = useState("");
const [files, setFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
useEffect(() => {
if (encoder.data?.model_path) setModelPath(encoder.data.model_path);
}, [encoder.data?.model_path]);
const applyStatus = (status: Awaited<ReturnType<typeof getLocalWikiEncoderStatus>>) => {
queryClient.setQueryData(["llmwiki-local-encoder"], status);
setModelPath(status.model_path || "");
};
const configure = useMutation({
mutationFn: () => configureLocalWikiEncoder(modelPath.trim()),
onSuccess: (status) => { applyStatus(status); toast.success("编码器目录已保存"); },
onError: (error) => toast.error(messageOf(error)),
});
const upload = useMutation({
mutationFn: () => uploadLocalWikiEncoder(files, setUploadProgress),
onMutate: () => setUploadProgress(0),
onSuccess: (status) => {
applyStatus(status);
setFiles([]);
toast.success(`已保存 ${status.uploaded_files ?? 0} 个模型文件`);
},
onError: (error) => toast.error(messageOf(error)),
});
const start = useMutation({
mutationFn: startLocalWikiEncoder,
onSuccess: (status) => {
applyStatus(status);
toast.success(status.state === "ready" ? "本地编码器已就绪" : "本地编码器正在独立启动");
},
onError: (error) => toast.error(messageOf(error)),
});
const status = encoder.data;
const selectedBytes = files.reduce((sum, file) => sum + file.size, 0);
const formatSize = (value: number) => value >= 1024 ** 3
? `${(value / 1024 ** 3).toFixed(2)} GB`
: `${(value / 1024 ** 2).toFixed(1)} MB`;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base"><Cpu className="size-5" />DeerFlow 本地编码器</CardTitle>
<CardDescription>仅管理员可见。配置服务器上已有的 BGE-M3 目录,或直接上传完整离线模型目录;编码器作为独立进程启动,不影响 DeerFlow 其他服务。</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex flex-wrap items-center gap-2 text-sm">
<Badge variant={status?.state === "failed" ? "destructive" : status?.state === "ready" ? "default" : "secondary"}>
{status?.state === "ready" ? "已就绪" : status?.state === "starting" ? "启动中" : status?.state === "failed" ? "启动失败" : "未启动"}
</Badge>
<span className="text-muted-foreground">{status ? `${status.model} · ${status.dimensions || "自动探测"} 维 · ${status.process_isolation ? "独立进程" : "主进程"}` : "正在读取状态"}</span>
{status?.model_files_ready && <Badge variant="outline">模型文件已就绪</Badge>}
</div>
{encoder.error && <p className="text-sm text-destructive">{messageOf(encoder.error)}</p>}
{status?.last_error && <p className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">{status.last_error}</p>}
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="local-encoder-path">使用服务器已有目录</label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input id="local-encoder-path" value={modelPath} onChange={(event) => setModelPath(event.target.value)} placeholder="例如 /opt/deerflow/models/bge-m3" />
<Button variant="outline" disabled={!modelPath.trim() || configure.isPending} onClick={() => configure.mutate()}>
{configure.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}保存目录
</Button>
</div>
<p className="text-xs text-muted-foreground">目录内需包含 <code>onnx/model.onnx</code>(推荐)或根目录 <code>model.onnx</code>。</p>
</div>
<div className="space-y-2 rounded-xl border border-dashed p-4">
<div className="flex items-center gap-2 text-sm font-medium"><Upload className="size-4" />从当前电脑上传模型目录</div>
<Input
type="file"
multiple
{...{ webkitdirectory: "" }}
onChange={(event) => setFiles(Array.from(event.currentTarget.files ?? []))}
/>
{upload.isPending && (
<div className="space-y-1">
<Progress value={uploadProgress} />
<p className="text-right text-xs text-muted-foreground">上传进度 {uploadProgress}%</p>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{files.length ? `已选择 ${files.length} 个文件,共 ${formatSize(selectedBytes)}` : "请选择完整 BGE-M3 目录。大文件会落到运行目录,不写入源码仓库。"}
</p>
<Button disabled={!files.length || upload.isPending} onClick={() => upload.mutate()}>
{upload.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Upload className="mr-2 size-4" />}
{upload.isPending ? "正在上传,请勿关闭页面" : "上传并设为当前模型"}
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={!status?.model_files_ready || start.isPending || status?.state === "starting" || status?.state === "ready"} onClick={() => start.mutate()}>
{(start.isPending || status?.state === "starting") && <Loader2 className="mr-2 size-4 animate-spin" />}
{status?.state === "ready" ? "编码器已启动" : status?.state === "failed" ? "重新启动" : "一键启动编码器"}
</Button>
<Button variant="ghost" disabled={encoder.isFetching} onClick={() => void encoder.refetch()}>
<RefreshCw className={`mr-2 size-4 ${encoder.isFetching ? "animate-spin" : ""}`} />刷新状态
</Button>
</div>
</CardContent>
</Card>
);
}
export function ConnectionSettings() {
const settings = useLlmWikiSettings(true);
const actions = useLlmWikiSettingsActions();
@@ -0,0 +1,221 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Archive,
ArrowLeft,
Download,
HardDrive,
Loader2,
PackagePlus,
RefreshCw,
} from "lucide-react";
import { Navigate, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { KnowledgeProductNav } from "@/components/workspace/knowledge-product-nav";
import { getStoredAuth } from "@/core/auth";
import {
createLlmWikiExportPackage,
listLlmWikiExportPackages,
} from "@/strategy-components/api/assistant-knowledge";
import { useLlmWikiKnowledgeBase, useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import { WikiPackageTransfers } from "./WikiPackageTransfers";
const RUNNING = new Set(["queued", "running"]);
const STATUS_LABEL: Record<string, string> = {
queued: "排队中",
running: "整理中",
completed: "已完成",
failed: "失败",
};
function formatBytes(value?: number | null) {
let current = Number(value || 0);
if (!current) return "—";
const units = ["B", "KB", "MB", "GB", "TB"];
let unit = 0;
while (current >= 1024 && unit < units.length - 1) {
current /= 1024;
unit += 1;
}
return `${current >= 10 || unit === 0 ? current.toFixed(0) : current.toFixed(1)} ${units[unit]}`;
}
export default function WeKnoraPackageTransfersPage() {
const { noteId = "" } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isAdmin = getStoredAuth()?.system_role === "admin";
const runtime = useLlmWikiRuntime();
const knowledge = useLlmWikiKnowledgeBase(noteId || null, !!noteId);
const jobsKey = ["llmwiki-export-packages", noteId];
const jobs = useQuery({
queryKey: jobsKey,
queryFn: () => listLlmWikiExportPackages(noteId),
enabled: isAdmin && !!noteId && runtime.data?.provider === "weknora",
refetchInterval: (query) =>
query.state.data?.some((job) => RUNNING.has(job.status)) ? 3000 : false,
});
const createExport = useMutation({
mutationFn: () => createLlmWikiExportPackage(noteId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: jobsKey });
toast.success("已创建后台整理任务,完成后可在本页下载");
},
onError: (error) => toast.error(error.message),
});
if (!isAdmin) return <Navigate to="/page/workspace/knowledge" replace />;
return (
<div className="flex size-full min-h-0 flex-col bg-muted/20">
<KnowledgeProductNav />
<main className="min-h-0 flex-1 overflow-auto px-5 py-6 lg:px-8">
<div className="mx-auto max-w-6xl space-y-6">
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() =>
navigate(
`/page/workspace/knowledge/${encodeURIComponent(noteId)}`,
)
}
>
<ArrowLeft className="size-5" />
</Button>
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Archive className="size-5" />
</div>
<div className="min-w-0">
<h1 className="truncate text-xl font-semibold">
知识库数据包任务
</h1>
<p className="truncate text-sm text-muted-foreground">
{knowledge.data?.name || "正在读取知识库"} ·
大文件在后台整理,不占用详情页面
</p>
</div>
</div>
<Button
onClick={() => createExport.mutate()}
disabled={createExport.isPending || !knowledge.data}
>
{createExport.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<PackagePlus className="mr-2 size-4" />
)}
新建导出任务
</Button>
</header>
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0 border-b">
<div>
<CardTitle className="text-base">
向量化数据包导出记录
</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
每次生成独立记录;即使文件达到数
GB,也只在后台落盘完成后提供下载。
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => void jobs.refetch()}
disabled={jobs.isFetching}
>
<RefreshCw
className={`mr-2 size-4 ${jobs.isFetching ? "animate-spin" : ""}`}
/>
刷新
</Button>
</CardHeader>
<CardContent className="p-0">
{jobs.data?.map((job) => {
const counts = job.counts || {};
const ready =
job.status === "completed" &&
job.file_exists &&
job.package_download_url;
return (
<div
key={job.id}
className="grid gap-4 border-b p-5 last:border-b-0 md:grid-cols-[auto_1fr_auto] md:items-center"
>
<div
className={`flex size-10 items-center justify-center rounded-full ${job.status === "completed" ? "bg-emerald-100 text-emerald-700" : job.status === "failed" ? "bg-destructive/10 text-destructive" : "bg-amber-100 text-amber-700"}`}
>
{RUNNING.has(job.status) ? (
<Loader2 className="size-4 animate-spin" />
) : (
<HardDrive className="size-4" />
)}
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">
任务 {job.id.slice(0, 8)}
</span>
<Badge variant="outline">
{STATUS_LABEL[job.status] || job.status}
</Badge>
<span className="text-xs text-muted-foreground">
{job.created_at
? new Date(job.created_at).toLocaleString()
: "—"}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-sm text-muted-foreground">
<span>
Wiki {counts.wiki_page ?? counts.wiki_pages ?? 0}
</span>
<span>向量 {counts.vector ?? counts.vectors ?? 0}</span>
<span>文件 {formatBytes(job.file_size_bytes)}</span>
<span>阶段 {job.phase}</span>
</div>
{job.error && (
<p className="mt-2 text-sm text-destructive">
{job.error}
</p>
)}
</div>
{ready ? (
<Button asChild variant="outline">
<a href={job.package_download_url}>
<Download className="mr-2 size-4" />
下载数据包
</a>
</Button>
) : (
<Button variant="outline" disabled>
{job.status === "failed" ? "整理失败" : "等待完成"}
</Button>
)}
</div>
);
})}
{!jobs.isLoading && !jobs.data?.length && (
<div className="px-6 py-14 text-center text-sm text-muted-foreground">
暂无导出记录。点击右上角“新建导出任务”开始整理。
</div>
)}
{jobs.isLoading && (
<div className="flex items-center justify-center px-6 py-14 text-sm text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />
加载导出记录
</div>
)}
</CardContent>
</Card>
<WikiPackageTransfers id={noteId} />
</div>
</main>
</div>
);
}
@@ -9,7 +9,7 @@ import {
Search,
X,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -49,12 +49,14 @@ export function WikiLibraryReader({
onNavigate,
pageHref,
mappingId,
initiallyCollapseTree = false,
}: {
pages: LibraryWikiPage[];
activePage: LibraryWikiPage | null;
onNavigate: (slug: string) => void;
pageHref: (slug: string) => string;
mappingId?: string;
initiallyCollapseTree?: boolean;
}) {
const [query, setQuery] = useState("");
const [kind, setKind] = useState("");
@@ -62,6 +64,7 @@ export function WikiLibraryReader({
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [graph, setGraph] = useState(false);
const [directoryOpen, setDirectoryOpen] = useState(false);
const initialTreeCollapseApplied = useRef(false);
const navigate = (slug: string) => {
onNavigate(slug);
setDirectoryOpen(false);
@@ -103,6 +106,13 @@ export function WikiLibraryReader({
const bySlug = useMemo(() => new Map(pages.map((p) => [p.slug, p])), [pages]);
useEffect(() => {
const ancestors: string[] = [];
const branches: string[] = [];
const collectBranches = (nodes: WikiTreeNode[]) => {
for (const node of nodes) {
if (node.children.length) branches.push(node.key);
collectBranches(node.children);
}
};
const visit = (nodes: WikiTreeNode[], parents: string[]): boolean =>
nodes.some((node) => {
if (node.page?.slug === activePage?.slug) {
@@ -111,12 +121,24 @@ export function WikiLibraryReader({
}
return visit(node.children, [...parents, node.key]);
});
collectBranches(tree.roots);
visit(tree.roots, []);
if (
initiallyCollapseTree &&
!initialTreeCollapseApplied.current &&
tree.roots.length
) {
initialTreeCollapseApplied.current = true;
setCollapsed(
new Set(branches.filter((key) => !ancestors.includes(key))),
);
return;
}
setCollapsed(
(current) =>
new Set([...current].filter((key) => !ancestors.includes(key))),
);
}, [activePage?.slug, tree]);
}, [activePage?.slug, initiallyCollapseTree, tree]);
const links = useMemo(() => {
const outgoing = new Set(activePage?.out_links || []);
for (const match of (activePage?.content || "").matchAll(
+41 -8
View File
@@ -1550,10 +1550,12 @@ detail. Never call `/knowledge-search` from internal search, RAG, or the
`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`. `enabled` turns on local vector search and manual
vectorization; `auto_sync` is the separate opt-in for the lease-protected
scheduler. The scheduler sleeps for one full configured interval before its
first scan, so application startup must never trigger immediate vectorization.
`local_index.runtime`. `enabled` turns on local vector search and `auto_sync`
defaults to the lease-protected background scheduler. The scheduler performs
an immediate incremental scan at startup, then rescans every configured
interval so new WeKnora libraries and asynchronously generated Wiki pages are
picked up without a manual vectorization click. Unchanged content must reuse
existing vectors.
Vectors are normalized little-endian float32 BLOBs. A page mirror and all of
its vectors are replaced in one transaction only after strict batched
@@ -1735,7 +1737,9 @@ snapshot remains authoritative on failure. A binding may have only one active
item (`active_dedupe_key` plus atomic lease claims). Skill distillation targets
only writable WeKnora Wiki knowledge bases in this product version; reject direct
assistant-base targets, non-Wiki/FAQ/conversation/readonly targets, and direct
Neo4j writes. Detach/delete operates on source contributions, not canonical rows,
Neo4j writes. The create/list/resync/retry/detach flow is available to ordinary
users for their own visible skills, writable knowledge bases, jobs and bindings;
administrator-only auto-sync/review APIs remain privileged. Detach/delete operates on source contributions, not canonical rows,
and shared entities/relations are retired only when their final contribution is
gone.
@@ -1745,7 +1749,10 @@ administrators own all mutations. Assistant bases only accept WeKnora export
packages or imports triggered from a WeKnora mapping. Do not add direct skill
imports or arbitrary local-file imports to assistant knowledge. Importing into a
custom assistant base must also mirror the package into the global base for
cross-source entity alignment.
cross-source entity alignment. Admins may rename any assistant base and delete
custom bases. Deletion removes the custom base's Wiki pages, vectors, entities,
relations, import audit rows, and package files; the global base is never
deletable because it owns full-library aggregation.
### Wiki package and local encoder invariants (2026-09)
@@ -1762,16 +1769,42 @@ cross-source entity alignment.
- Preserve normalized float32 base64 bytes exactly. Same dimension alone is not
model compatibility. Assistant vector retrieval is fingerprint-gated cosine
search, and any keyword fallback must be labeled as such.
Preserve a valid zero section_index. Export/search vectors belonging to the
current Wiki revision's import job, not stale contributions from other sources.
Preserve a valid zero section_index. Export/search vectors belonging to each
current source snapshot (`source.last_import_job_id`), not merely the page's
current revision: one aligned page may intentionally contain current vectors
from several sources.
- The `local` embedding provider uses FastEmbed with official BAAI/bge-m3 ONNX,
1024 dimensions, bounded CPU batches and a per-client async lock. Run inference
off the event loop. Existing page vectors are reusable; zero-vector nonempty
pages need backfill, not a forced rebuild of every page.
FastEmbed is a standard backend dependency. Encoder configuration, directory
upload and warmup live only in the administrator global settings dialog.
A runtime `system_settings.json` path override wins over config.yaml; uploaded
files are streamed into `.deer-flow/models/bge-m3`, never the source tree.
Encoder warmup is explicitly started from the settings UI/API and must never block application startup
or turn a model-load failure into a DeerFlow service failure. Local provider
is offline-only: require local_model_path or DEERFLOW_BGE_M3_MODEL_PATH and
never let the application download model weights. Keep
`local_process_isolation=true` by default: inference runs in the bundled JSONL
worker subprocess, and shutdown must terminate that worker without waiting on
model teardown.
- Every successful mapping snapshot is mirrored to the global assistant base,
including empty snapshots so deleted source pages are retired. Re-import is
source-replacement semantics: absent Wiki/chunk/entity/relation pages must not
remain visible merely because they existed in an older source snapshot.
- Provider graph nodes bind to their original wiki_slug. They must not create
duplicate synthetic entity/relation articles. Store original Wiki metadata
separately from assistant workflow status. Count Wiki pages separately from
raw document chunks.
- The global base aligns entities by normalized display name and aliases across
source records, and aligns content Wiki pages by normalized title. Do not use
source slug as the global canonical identity. Merge unique knowledge blocks
while retaining source contributions/origins. Uploaded packages must rebind
the temporary upload source to the stable WeKnora knowledge-base id in the
package, so re-upload is source-replacement rather than append. Retrieval
candidates exclude summary/index/directory pages; keyword matching, vector
snippets, citations and prompts must use knowledge body/sections, never page
summary as a substitute.
- Migration `20260903_01` adds nullable page metadata and merges the pre-existing
knowledge/workflow Alembic heads. Regression coverage is in
`tests/test_wiki_package_roundtrip.py`, `test_llmwiki_wiki_sync.py` and
+37 -16
View File
@@ -912,13 +912,12 @@ 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 the three
`llmwiki.local_wiki_index.embedding` values (`model`, `base_url`, and
`api_key`), then change only `enabled` to `true` in `config.yaml`. Manual
vectorization is the default. Set `auto_sync: true` only when periodic
background synchronization is wanted; the scheduler waits one full interval
after startup before its first scan, so boot does not trigger a rebuild.
matched Wiki pages. It never falls back to raw documents or chunks. Configure
`llmwiki.local_wiki_index.embedding`, then enable the local index in
`config.yaml`. `auto_sync` defaults to true: the scheduler performs one
immediate incremental scan at startup and then scans every configured interval
(30 seconds by default). Wiki changes are content-hash checked, so unchanged
pages reuse their vectors instead of being encoded again.
`database_batch_size` caps each vector insert statement (default `50`) while
the page replacement remains atomic, preventing a large Wiki from flooding
the SQL connection in one statement.
@@ -1121,45 +1120,67 @@ The database schema is introduced by Alembic revision `20260901_01`.
### Wiki 流转与本地小批量编码
技能归纳现在上传清理后的知识正文,等待 WeKnora 完成 Wiki 分析,再建立本地
所有登录用户都可在「技能 → 归纳到知识库」中多选自己可见的技能,并归纳到一个
自己可写的 WeKnora Wiki 知识库;用户只能看到和维护自己发起的归纳记录,管理员
接口仍可查看全量记录。技能归纳上传清理后的知识正文,等待 WeKnora 完成 Wiki 分析,再建立本地
Wiki 向量并通过落盘导出包自动沉淀到「知识梳理」。内置技能没有数据库所有权记录
也可以归纳。若解析结束却没有 Wiki,会提示检查生成模型和资料;文档解析完成
不等于 Wiki 生成成功,例如生成服务余额不足仍可能表现为文档已完成。
管理员可以在普通知识库直接生成向量数据包,每次操作有独立后台记录和下载入口。
助手知识库支持上传包、异步导入和导出;新建空的普通 Wiki 库可上传助手导出包。
助手知识库支持上传包、异步导入和导出;新建空的普通 Wiki 库可上传助手导出包。管理员可编辑助手知识库名称与描述,也可删除自建助手知识库及其 Wiki、向量、实体、关系、导入记录和落盘数据包;承担全库沉淀的「知识梳理总库」允许编辑但禁止删除。
包保留正文、别名/标签、目录分类、双向链接、实体关系及 float32 向量。
回流时通过 WeKnora API 重建目录/页面,向量进入 DeerFlow 本地 Wiki 索引,
**不直接写 WeKnora 的底层向量或图数据库**。普通与助手 Wiki 使用同一个阅读组件。
小批量 CPU 编码可配置如下,无需额外部署 HTTP 编码服务:
```bash
uv sync --extra local-embedding
```
```yaml
llmwiki:
local_wiki_index:
enabled: true
auto_sync: false
auto_sync: true
sync_interval_seconds: 30
embedding:
provider: local
model: bge-embedding-m3
dimensions: 1024
batch_size: 2
local_threads: 2
local_process_isolation: true
# 离线部署时可指定已下载的 BAAI/bge-m3 模型目录:
# local_model_path: /opt/models/bge-m3
```
首次使用需要预下载官方 ONNX 权重(约 2.3 GB),默认持久缓存位于
`~/.cache/deerflow/embeddings`,也可设置 `local_cache_dir`。权重不进入业务源码仓库。
本地编码依赖随标准后端安装。将完整 BGE-M3 模型目录提前放到离线服务器,并通过
`local_model_path` 或 `DEERFLOW_BGE_M3_MODEL_PATH` 指向它。管理员也可在
「设置和更多 → 设置 → 本地编码器」中选择服务器目录,或从浏览器上传完整模型目录;
页面配置持久化到运行目录的 `system_settings.json`,上传文件落到
`.deer-flow/models/bge-m3`,均不进入源码仓库。系统强制 `local_files_only`,
**不会访问互联网或下载模型**。管理员可在同一设置页点击「一键启动编码器」异步加载;该操作不挂在应用启动流程,加载失败不会
中断 DeerFlow 主服务。默认由独立低优先级子进程承载模型内存,正常停止 DeerFlow
时会一并回收该子进程;编码进程异常退出只会将当前向量任务标记失败。模型目录应包含
`onnx/model.onnx`(官方权重约 2.3 GB)及对应 tokenizer/config 文件。权重不进入业务源码仓库。
浏览器目录上传采用磁盘暂存和 4 MB 分块复制,页面显示上传进度;经 Nginx 等反向
代理部署时仍需把请求体上限和读写超时配置到可承载完整模型目录。
相同模型指纹、相同内容的页面复用现有向量;短正文/摘要至少保留一个非空分段。
数据包导入不重新编码,模型指纹或维度不一致时明确拒绝,不能只按维度混用模型。
问答编码查询本身仍需可用的同一模型。WeKnora 的原始文档向量不能当作生成后
Wiki 正文的向量使用,两种文本需要分别建立各自的首次索引。
Wiki 页面新增、编辑、删除及文档上传/重解析都会立即触发一次增量检查;WeKnora
后台稍后才完成的 Wiki 抽取由 30 秒周期补扫接管。每次成功向量化后自动把该普通
知识库的最新完整快照同步到「知识梳理总库」,包括服务重启前已有库和新建库;来源
页面被删除时也会在总库中退役,不保留失效 Wiki。
「知识梳理总库」不是按来源简单堆叠:导入时会规范化全/半角、空白、标点及常见实体
类型,并利用实体别名对齐同一人物、组织、产品或概念;不同来源中同标题的实体 Wiki
只保留一个规范页面,正文按去重后的知识块合并,来源与实体/关系贡献仍分别留痕。
重复上传同一个 WeKnora 导出包时,以包内稳定知识库 ID 识别同一来源并执行快照替换,
不会再产生一批新的片段副本。已有历史重复数据会在总库首次读取、检索、导出或下次
同步时自动归并。助手知识检索排除目录、索引和摘要页;关键词只查标题与知识正文,
向量检索返回实际命中的正文片段,问答上下文和检索卡片均不再用摘要代替知识。
大包采用流式 JSONL 落盘、同盘临时 SQLite 分阶段读取及异步导入,避免一次性将
整个包加载进内存。部署需预留包、暂存索引和业务数据库空间,并配置代理上传大小
与超时。当前不宣称断点续传或 GB 级压力验收已完成。
@@ -379,6 +379,17 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
resolved_llmwiki = get_resolved_llmwiki_runtime(config)
weknora_client = build_weknora_client(resolved_llmwiki) if resolved_llmwiki.weknora_enabled else None
embedding_config = local_index_config.embedding
# The administrator may select or upload the offline encoder from the
# UI. This runtime-owned value deliberately overrides config.yaml and
# survives application restarts without modifying release assets.
if embedding_config.provider == "local":
from deerflow.config.system_settings import load_system_settings
runtime_model_path = load_system_settings().local_wiki_encoder.model_path.strip()
if runtime_model_path:
embedding_config = embedding_config.model_copy(
update={"local_model_path": runtime_model_path}
)
embedding_configured = bool(
embedding_config.enabled
and (embedding_config.base_url or embedding_config.provider == "local")
@@ -49,9 +49,14 @@ async def _sync_once(app: FastAPI) -> None:
async def local_wiki_index_scheduler_loop(app: FastAPI) -> None:
interval = app.state.config.llmwiki.local_wiki_index.sync_interval_seconds
logger.info(
"Local Wiki auto-sync scheduler started; first scan delayed by %s seconds",
"Local Wiki auto-sync scheduler started; first scan is immediate, interval=%s seconds",
interval,
)
while True:
try:
await _sync_once(app)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Local Wiki auto-sync scheduler tick failed (non-fatal)")
await asyncio.sleep(interval)
await _sync_once(app)
@@ -170,13 +170,14 @@ def _source_from_result(result: dict[str, Any], mapping: dict[str, Any], index:
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"))
content = content or str(result.get("content") or result.get("content_markdown") or "")
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),
"snippet": _truncate(content, 360),
"content": content,
"source": mapping.get("name") or "LLMWiki",
"score": float(result.get("score") or 0),
@@ -222,7 +223,9 @@ def _source_from_assistant_result(result: dict[str, Any], index: int) -> dict[st
or result.get("summary")
or ""
)
snippet = str(result.get("summary") or content)
# The retrieval citation must expose the matched knowledge itself. A page
# summary is display metadata and can omit the fact that answered the query.
snippet = content
raw = {
**result,
"source": "助手知识库",
@@ -348,7 +351,10 @@ async def build_llmwiki_rag_context(
mapping = mapping_by_id.get(str(result.get("knowledge_base_id") or ""))
if mapping is None:
continue
sources.append(_source_from_result(result, mapping, len(sources) + 1))
source = _source_from_result(result, mapping, len(sources) + 1)
if not str(source.get("content") or "").strip():
continue
sources.append(source)
if len(sources) >= _MAX_SOURCES:
break
@@ -150,6 +150,31 @@ def _list_export_manifests(mapping_id: str, *, limit: int) -> list[dict[str, Any
return rows[:limit]
def _delete_base_package_files(base_id: str, import_job_ids: list[str]) -> None:
"""Delete package artifacts owned by one assistant base only."""
root = _package_root()
if not root.is_dir():
return
job_ids = {str(value) for value in import_job_ids if value}
scope = f"assistant:{base_id}"
for manifest_path in root.glob("*.manifest.json"):
try:
value = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
continue
if not isinstance(value, dict) or str(value.get("mapping_id") or "") != scope:
continue
job_id = str(value.get("id") or "")
if job_id:
job_ids.add(job_id)
manifest_path.unlink(missing_ok=True)
for job_id in job_ids:
path = package_path(root, job_id)
path.unlink(missing_ok=True)
path.with_suffix(".tmp").unlink(missing_ok=True)
path.with_suffix(".manifest.json").unlink(missing_ok=True)
async def _actor(request: Request, *, admin: bool = False) -> str | None:
user = await get_optional_user_from_request(request)
if user is None:
@@ -175,6 +200,11 @@ class BaseCreateRequest(BaseModel):
description: str = Field(default="", max_length=2048)
class BaseUpdateRequest(BaseModel):
name: str = Field(min_length=1, max_length=255)
description: str = Field(default="", max_length=2048)
class SkillImportRequest(BaseModel):
skill_names: list[str] = Field(min_length=1, max_length=200)
force: bool = False
@@ -224,6 +254,51 @@ async def get_base_by_id(request: Request, base_id: str) -> dict[str, Any]:
return {"base": base}
@router.patch("/bases/{base_id}")
async def update_base(request: Request, base_id: str, body: BaseUpdateRequest) -> dict[str, Any]:
await _actor(request, admin=True)
base = await _store(request).update_base(
base_id,
name=body.name,
description=body.description,
)
if base is None:
raise HTTPException(status_code=404, detail="助手知识库不存在")
return {"base": base}
@router.delete("/bases/{base_id}", status_code=204)
async def delete_base(request: Request, base_id: str) -> None:
await _actor(request, admin=True)
store = _store(request)
base = await store.get_base(base_id)
if base is None:
raise HTTPException(status_code=404, detail="助手知识库不存在")
if base.get("is_global"):
raise HTTPException(status_code=409, detail="知识梳理总库承担全库沉淀,不能删除")
jobs = await store.list_jobs(limit=500, base_id=base_id)
if any(job.get("status") in {"queued", "running"} for job in jobs):
raise HTTPException(status_code=409, detail="知识库正在导入数据,请等待任务完成后再删除")
exports = await asyncio.to_thread(
_list_export_manifests,
f"assistant:{base_id}",
limit=10_000,
)
if any(job.get("status") in {"queued", "running"} for job in exports):
raise HTTPException(status_code=409, detail="知识库正在生成导出包,请等待任务完成后再删除")
try:
deleted = await store.delete_base(base_id)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if not deleted:
raise HTTPException(status_code=404, detail="助手知识库不存在")
await asyncio.to_thread(
_delete_base_package_files,
base_id,
[str(job.get("id") or "") for job in jobs],
)
@router.get("/wiki-pages")
async def list_wiki_pages(
request: Request,
@@ -287,7 +362,13 @@ async def search(
store = _store(request)
base = await store.get_base(base_id)
embedding = getattr(request.app.state, "llmwiki_embedding", None)
rows = await store.search_pages(base_ids=[base["id"]] if base else [], query=q, limit=20, include_chunks=False, embedding_client=embedding)
rows = await store.search_pages(
base_ids=[base["id"]] if base else [],
query=q,
limit=20,
include_chunks=True,
embedding_client=embedding,
)
return {"results": rows, "total": len(rows), "retrieval_mode": rows[0].get("retrieval_mode", "keyword") if rows else "empty"}
@@ -255,7 +255,10 @@ def _schedule_local_wiki_sync(request: Request, mapping: dict[str, Any]) -> None
if service is not None and config.enabled and config.auto_sync 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))
# Event-driven refresh is incremental. Content hash + embedding
# fingerprint decide which pages need encoding; unchanged Wiki pages
# must retain and reuse their existing vectors.
spawn_local_wiki_index_job(request.app, service.sync_mapping(mapping, force=False))
async def _actor(request: Request) -> tuple[str, bool]:
@@ -3058,6 +3061,7 @@ async def create_knowledge_base(request: Request, body: KnowledgeBaseCreate) ->
except Exception:
logger.exception("Failed to compensate orphan WeKnora knowledge base")
raise HTTPException(status_code=500, detail="Failed to register the knowledge base") from None
_schedule_local_wiki_sync(request, row)
return _mapping_view(row, remote, actor_user_id=user_id, is_admin=is_admin)
@@ -3345,12 +3349,14 @@ async def upload_document(request: Request, mapping_id: str, file: UploadFile =
if not file.filename:
raise HTTPException(status_code=422, detail="File name is required")
try:
return await _client_or_503().upload_document(
result = await _client_or_503().upload_document(
str(row["weknora_id"]),
filename=file.filename,
content=content,
content_type=file.content_type or "application/octet-stream",
)
_schedule_local_wiki_sync(request, row)
return result
except WeKnoraError as exc:
_raise_upstream(exc)
@@ -3362,7 +3368,9 @@ async def import_document_url(request: Request, mapping_id: str, body: Knowledge
if not url.lower().startswith(("http://", "https://")):
raise HTTPException(status_code=422, detail="URL must start with http:// or https://")
try:
return await _client_or_503().import_document_url(str(row["weknora_id"]), url=url)
result = await _client_or_503().import_document_url(str(row["weknora_id"]), url=url)
_schedule_local_wiki_sync(request, row)
return result
except WeKnoraError as exc:
_raise_upstream(exc)
@@ -3371,11 +3379,13 @@ async def import_document_url(request: Request, mapping_id: str, body: Knowledge
async def create_manual_document(request: Request, mapping_id: str, body: ManualKnowledgeCreate) -> dict[str, Any]:
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
try:
return await _client_or_503().create_manual_document(
result = await _client_or_503().create_manual_document(
str(row["weknora_id"]),
title=body.title.strip(),
content=body.content,
)
_schedule_local_wiki_sync(request, row)
return result
except WeKnoraError as exc:
_raise_upstream(exc)
@@ -3387,6 +3397,7 @@ async def delete_document(request: Request, mapping_id: str, document_id: str) -
try:
await _verify_document(client, row, document_id)
await client.delete_document(document_id)
_schedule_local_wiki_sync(request, row)
except WeKnoraError as exc:
_raise_upstream(exc)
@@ -3397,7 +3408,9 @@ async def reprocess_document(request: Request, mapping_id: str, document_id: str
client = _client_or_503()
try:
await _verify_document(client, row, document_id)
return await client.reprocess_document(document_id)
result = await client.reprocess_document(document_id)
_schedule_local_wiki_sync(request, row)
return result
except WeKnoraError as exc:
_raise_upstream(exc)
@@ -2,15 +2,20 @@
from __future__ import annotations
import asyncio
import logging
import shutil
from pathlib import Path, PurePosixPath
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, File, Form, HTTPException, Query, Request, UploadFile
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.config.runtime_paths import resolve_path, runtime_home
from deerflow.config.system_settings import load_system_settings, save_system_settings
from deerflow.integrations.weknora.local_index.retrieval import is_fully_vectorized
from deerflow.integrations.weknora.local_index.search import WikiIndexError
@@ -37,6 +42,10 @@ class WikiIndexSettingsRequest(BaseModel):
external_search_enabled: bool | None = None
class LocalEncoderConfigRequest(BaseModel):
model_path: str = Field(min_length=1, max_length=4096)
async def _actor(request: Request) -> tuple[str, bool]:
user = await get_optional_user_from_request(request)
if user is None:
@@ -55,6 +64,149 @@ def _services(request: Request):
return config, search, index_store
def _local_encoder(request: Request):
config = request.app.state.config.llmwiki.local_wiki_index.embedding
embedding = getattr(request.app.state, "llmwiki_embedding", None)
if config.provider != "local":
raise HTTPException(status_code=409, detail={"code": "EMBEDDING_NOT_LOCAL", "message": "当前 embedding provider 不是 local"})
if embedding is None:
raise HTTPException(status_code=503, detail={"code": "LOCAL_ENCODER_UNAVAILABLE", "message": "本地编码器未配置或未启用"})
return embedding
def _validated_encoder_path(raw_path: str) -> Path:
path = resolve_path(raw_path.strip())
if not path.is_dir():
raise HTTPException(status_code=422, detail="编码器目录不存在")
if not ((path / "onnx" / "model.onnx").is_file() or (path / "model.onnx").is_file()):
raise HTTPException(status_code=422, detail="编码器目录缺少 onnx/model.onnx 或 model.onnx")
return path
def _safe_relative_upload_path(value: str) -> Path:
normalized = str(value or "").replace("\\", "/").strip("/")
candidate = PurePosixPath(normalized)
if not normalized or candidate.is_absolute() or ".." in candidate.parts:
raise HTTPException(status_code=422, detail=f"非法模型文件路径:{value}")
if len(normalized) > 512:
raise HTTPException(status_code=422, detail="模型文件路径过长")
return Path(*candidate.parts)
def _copy_encoder_uploads(files: list[UploadFile], paths: list[str], staging: Path) -> int:
total = 0
for upload, raw_path in zip(files, paths, strict=True):
relative = _safe_relative_upload_path(raw_path)
destination = staging / relative
destination.parent.mkdir(parents=True, exist_ok=True)
upload.file.seek(0)
with destination.open("wb") as output:
while chunk := upload.file.read(4 * 1024 * 1024):
output.write(chunk)
total += len(chunk)
return total
def _activate_uploaded_encoder(staging: Path) -> Path:
model_root = (runtime_home() / "models").resolve()
model_root.mkdir(parents=True, exist_ok=True)
active = (model_root / "bge-m3").resolve()
if active.parent != model_root:
raise RuntimeError("编码器目标目录越界")
backup = (model_root / f".bge-m3-backup-{uuid4().hex}").resolve()
if active.exists():
active.replace(backup)
try:
staging.replace(active)
except Exception:
if backup.exists() and not active.exists():
backup.replace(active)
raise
if backup.exists():
shutil.rmtree(backup)
return active
async def _save_encoder_path(request: Request, path: Path) -> dict[str, object]:
embedding = _local_encoder(request)
current = load_system_settings()
encoder_settings = current.local_wiki_encoder.model_copy(update={"model_path": str(path)})
save_system_settings(current.model_copy(update={"local_wiki_encoder": encoder_settings}))
await embedding.reset_local_encoder(model_path=str(path))
return embedding.local_encoder_status()
@router.get("/local-encoder/status")
async def local_encoder_status(request: Request) -> dict[str, object]:
_, is_admin = await _actor(request)
if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required")
return _local_encoder(request).local_encoder_status()
@router.post("/local-encoder/start", status_code=202)
async def start_local_encoder(request: Request) -> dict[str, object]:
_, is_admin = await _actor(request)
if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required")
try:
return _local_encoder(request).start_local_encoder()
except ValueError as exc:
raise HTTPException(status_code=409, detail={"code": "LOCAL_ENCODER_START_REJECTED", "message": str(exc)}) from None
@router.put("/local-encoder/config")
async def configure_local_encoder(request: Request, body: LocalEncoderConfigRequest) -> dict[str, object]:
_, is_admin = await _actor(request)
if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required")
return await _save_encoder_path(request, _validated_encoder_path(body.model_path))
@router.post("/local-encoder/upload")
async def upload_local_encoder(
request: Request,
files: list[UploadFile] = File(...),
paths: list[str] = Form(...),
) -> dict[str, object]:
"""Stream an offline model directory into runtime storage and activate it.
Starlette spools multipart files to disk, and the copy loop is chunked, so
multi-gigabyte ONNX models do not need to be loaded into Gateway memory.
"""
_, is_admin = await _actor(request)
if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required")
if not files or len(files) != len(paths):
raise HTTPException(status_code=422, detail="模型文件与相对路径不匹配")
if len(files) > 500:
raise HTTPException(status_code=422, detail="模型目录文件数不能超过 500")
staging_root = (runtime_home() / "model-uploads").resolve()
staging = (staging_root / uuid4().hex).resolve()
if staging.parent != staging_root:
raise HTTPException(status_code=500, detail="模型暂存目录越界")
staging.mkdir(parents=True, exist_ok=False)
try:
uploaded_bytes = await asyncio.to_thread(_copy_encoder_uploads, files, paths, staging)
_validated_encoder_path(str(staging))
await _local_encoder(request).reset_local_encoder()
active = await asyncio.to_thread(_activate_uploaded_encoder, staging)
status = await _save_encoder_path(request, active)
return {**status, "uploaded_files": len(files), "uploaded_bytes": uploaded_bytes}
except HTTPException:
raise
except OSError as exc:
logger.exception("Failed to store uploaded local encoder")
raise HTTPException(status_code=507, detail=f"编码器文件保存失败:{exc}") from None
finally:
for upload in files:
await upload.close()
if staging.exists():
await asyncio.to_thread(shutil.rmtree, staging, True)
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
@@ -1,4 +1,4 @@
"""Administrator APIs for skill knowledge bindings and matrix jobs."""
"""User-facing skill distillation APIs plus administrator maintenance APIs."""
from __future__ import annotations
@@ -21,17 +21,28 @@ from deerflow.integrations.weknora.runtime import (
get_resolved_llmwiki_runtime,
)
from deerflow.skill_knowledge import scan_skill
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.types import SkillCategory
router = APIRouter(prefix="/api/skill-knowledge", tags=["skill-knowledge"])
async def _require_admin(request: Request) -> str | None:
async def _actor(request: Request) -> tuple[str, bool]:
user = await get_optional_user_from_request(request)
if user is None:
return None
if getattr(user, "system_role", None) != "admin":
return "system", True
return str(user.id), getattr(user, "system_role", None) == "admin"
async def _require_admin(request: Request) -> str:
actor, is_admin = await _actor(request)
if not is_admin:
raise HTTPException(status_code=403, detail="技能知识归纳仅管理员可操作")
return str(user.id)
return actor
def _owned_by_actor(row: dict[str, Any] | None, actor: str, is_admin: bool) -> bool:
return bool(row and (is_admin or str(row.get("created_by") or "") == actor))
def _store(request: Request):
@@ -98,9 +109,15 @@ async def list_bindings(
skill_names: list[str] | None = Query(default=None),
target_id: str | None = Query(default=None),
include_detached: bool = Query(default=False),
mine_only: bool = Query(default=False),
) -> dict[str, Any]:
await _require_admin(request)
rows = await _store(request).list_bindings(skill_names=skill_names, target_id=target_id, include_detached=include_detached)
actor, is_admin = await _actor(request)
rows = await _store(request).list_bindings(
skill_names=skill_names,
target_id=target_id,
include_detached=include_detached,
created_by=actor if mine_only or not is_admin else None,
)
return {"bindings": rows}
@@ -164,13 +181,26 @@ async def disable_binding_auto_sync(request: Request, binding_id: str) -> dict[s
@router.post("/sync-jobs", status_code=202)
async def create_sync_job(request: Request, body: CreateSyncJobRequest) -> dict[str, Any]:
actor = await _require_admin(request)
actor, is_admin = await _actor(request)
# Built-in and legacy skills need not have an ownership row. Validate
# against the same running filesystem used by the skills list/executor.
missing = []
favorite_names: set[str] = set()
custom_root: Any = None
if not is_admin:
favorite_names = set(await request.app.state.skill_store.list_favorite_skill_names(actor))
storage_root = get_or_new_skill_storage(
app_config=request.app.state.config
).get_skills_root_path().resolve()
custom_root = (storage_root / SkillCategory.CUSTOM.value).resolve()
for name in body.skill_names:
try:
await asyncio.to_thread(_skill_directory, request.app.state.config, name)
skill_path = await asyncio.to_thread(_skill_directory, request.app.state.config, name)
if not is_admin:
visible = await request.app.state.skill_store.get_visible(name, actor)
is_public_filesystem_skill = not skill_path.resolve().is_relative_to(custom_root)
if visible is None and name not in favorite_names and not is_public_filesystem_skill:
missing.append(name)
except (FileNotFoundError, ValueError):
missing.append(name)
if missing:
@@ -182,9 +212,14 @@ async def create_sync_job(request: Request, body: CreateSyncJobRequest) -> dict[
raise HTTPException(status_code=422, detail="不支持技能直接归纳到助手知识库,请先归纳到 WeKnora 普通知识库")
if target.target_mode != "wiki":
raise HTTPException(status_code=422, detail="技能归纳目标仅支持 WeKnora Wiki 知识库")
mapping = await request.app.state.llmwiki_store.get_authorized(target.target_id, actor or "system", write=True, is_admin=True)
mapping = await request.app.state.llmwiki_store.get_authorized(
target.target_id,
actor,
write=True,
is_admin=is_admin,
)
if mapping is None:
raise HTTPException(status_code=404, detail=f"普通知识库不存在:{target.target_id}")
raise HTTPException(status_code=404, detail=f"普通知识库不存在或当前用户不可写:{target.target_id}")
data["target_name"] = target.target_name or str(mapping.get("name") or target.target_id)
data["remote_business_key"] = target.remote_business_key or f"weknora:kb:{mapping['weknora_id']}"
targets.append(data)
@@ -193,7 +228,7 @@ async def create_sync_job(request: Request, body: CreateSyncJobRequest) -> dict[
targets=targets,
created_by=actor,
force=body.force,
review_mode=body.review_mode,
review_mode=body.review_mode if is_admin else "off",
)
dispatcher = getattr(request.app.state, "skill_knowledge_dispatcher", None)
if dispatcher is not None:
@@ -202,23 +237,35 @@ async def create_sync_job(request: Request, body: CreateSyncJobRequest) -> dict[
@router.get("/sync-jobs")
async def list_sync_jobs(request: Request, limit: int = Query(default=50, ge=1, le=200)) -> dict[str, Any]:
await _require_admin(request)
return {"jobs": await _store(request).list_jobs(limit=limit)}
async def list_sync_jobs(
request: Request,
limit: int = Query(default=50, ge=1, le=200),
mine_only: bool = Query(default=False),
) -> dict[str, Any]:
actor, is_admin = await _actor(request)
return {
"jobs": await _store(request).list_jobs(
limit=limit,
created_by=actor if mine_only or not is_admin else None,
)
}
@router.get("/sync-jobs/{job_id}")
async def get_sync_job(request: Request, job_id: str) -> dict[str, Any]:
await _require_admin(request)
actor, is_admin = await _actor(request)
row = await _store(request).get_job(job_id)
if row is None:
if not _owned_by_actor(row, actor, is_admin):
raise HTTPException(status_code=404, detail="归纳任务不存在")
return row
@router.post("/sync-jobs/{job_id}/cancel")
async def cancel_sync_job(request: Request, job_id: str) -> dict[str, bool]:
await _require_admin(request)
actor, is_admin = await _actor(request)
row = await _store(request).get_job(job_id)
if not _owned_by_actor(row, actor, is_admin):
raise HTTPException(status_code=404, detail="归纳任务不存在")
if not await _store(request).cancel_job(job_id):
raise HTTPException(status_code=404, detail="归纳任务不存在")
return {"canceled": True}
@@ -226,7 +273,20 @@ async def cancel_sync_job(request: Request, job_id: str) -> dict[str, bool]:
@router.post("/sync-items/{item_id}/retry", status_code=202)
async def retry_sync_item(request: Request, item_id: str) -> dict[str, Any]:
await _require_admin(request)
actor, is_admin = await _actor(request)
jobs = await _store(request).list_jobs(limit=200, created_by=None if is_admin else actor)
item = next(
(item for job in jobs for item in job.get("items", []) if item.get("id") == item_id),
None,
)
if item is None:
raise HTTPException(status_code=404, detail="归纳任务项不存在")
if item.get("target_type") == "weknora":
mapping = await request.app.state.llmwiki_store.get_authorized(
str(item["target_id"]), actor, write=True, is_admin=is_admin
)
if mapping is None:
raise HTTPException(status_code=404, detail="目标知识库不存在或当前用户不可写")
row = await _store(request).requeue_item(item_id)
if row is None:
raise HTTPException(status_code=404, detail="归纳任务项不存在")
@@ -236,10 +296,16 @@ async def retry_sync_item(request: Request, item_id: str) -> dict[str, Any]:
@router.post("/bindings/{binding_id}/resync", status_code=202)
async def resync_binding(request: Request, binding_id: str) -> dict[str, Any]:
actor = await _require_admin(request)
actor, is_admin = await _actor(request)
binding = await _store(request).get_binding(binding_id)
if binding is None or not binding.get("active"):
if not _owned_by_actor(binding, actor, is_admin) or not binding.get("active"):
raise HTTPException(status_code=404, detail="绑定不存在")
if binding.get("target_type") == "weknora":
mapping = await request.app.state.llmwiki_store.get_authorized(
str(binding["target_id"]), actor, write=True, is_admin=is_admin
)
if mapping is None:
raise HTTPException(status_code=404, detail="目标知识库不存在或当前用户不可写")
target = {
"target_type": binding["target_type"],
"target_mode": binding["target_mode"],
@@ -260,10 +326,10 @@ async def resync_binding(request: Request, binding_id: str) -> dict[str, Any]:
@router.post("/bindings/{binding_id}/detach")
async def detach_binding(request: Request, binding_id: str, body: DetachRequest) -> dict[str, Any]:
await _require_admin(request)
actor, is_admin = await _actor(request)
store = _store(request)
binding = await store.get_binding(binding_id)
if binding is None:
if not _owned_by_actor(binding, actor, is_admin):
raise HTTPException(status_code=404, detail="绑定不存在")
if await store.has_active_item(binding_id):
raise HTTPException(status_code=409, detail="该绑定仍有运行中的归纳项,请先取消任务")
@@ -278,9 +344,9 @@ async def detach_binding(request: Request, binding_id: str, body: DetachRequest)
elif binding["target_type"] == "weknora":
mapping = await request.app.state.llmwiki_store.get_authorized(
str(binding["target_id"]),
"system",
actor,
write=True,
is_admin=True,
is_admin=is_admin,
)
runtime = get_resolved_llmwiki_runtime(request.app.state.config)
if mapping is None or not runtime.weknora_enabled:
+9 -3
View File
@@ -589,13 +589,14 @@ llmwiki:
timeout_seconds: 30
# 本地 Wiki 向量化:用于普通知识库“下载向量化数据包”和助手知识库向量检索。
# 已有相同内容哈希 + 相同 embedding fingerprint 的 Wiki 向量会直接复用,不会重复编码。
# local 模式使用 CPU BGE-M3,需安装 local-embedding 依赖并预下载官方 ONNX 权重。
# auto_sync=true 才会后台自动增量同步;服务启动时只启动定时器,不会立刻全量向量化。
# local 模式使用 CPU BGE-M3;离线包必须包含 fastembed,并提前放置完整 ONNX 权重。
# 启动后先补扫一次,此后按间隔自动增量向量化并沉淀到“知识梳理总库”。
local_wiki_index:
enabled: true
# 免登录 POST /api/knowledge/vector-search 返回的 Wiki 一键跳转链接前缀。
frontend_base_url: "http://127.0.0.1:5174"
auto_sync: false
auto_sync: true
sync_interval_seconds: 30
database_batch_size: 50
embedding:
provider: local
@@ -603,6 +604,11 @@ llmwiki:
dimensions: 1024
batch_size: 2
local_threads: 2
# 独立子进程承载大模型内存;编码器异常退出不会带停 DeerFlow 主服务。
local_process_isolation: true
# 离线部署必须指向提前放置好的完整 BGE-M3 模型目录;也可设置
# DEERFLOW_BGE_M3_MODEL_PATH。系统不会联网下载模型。
local_model_path: ""
base_url: ""
api_key: ""
@@ -78,6 +78,7 @@ class LocalWikiEmbeddingConfig(BaseModel):
api_key: str = Field(default="", repr=False)
local_model_path: str = ""
local_cache_dir: str = ""
local_process_isolation: bool = True
local_threads: int = Field(default=2, ge=1, le=32)
dimensions: int | None = Field(
default=None,
@@ -133,8 +134,8 @@ class LocalWikiIndexConfig(BaseModel):
default="http://127.0.0.1:5174",
description="DeerFlow frontend base URL used to build directly usable Wiki links in public vector-search results.",
)
auto_sync: bool = False
sync_interval_seconds: int = Field(default=120, ge=10, le=86400)
auto_sync: bool = True
sync_interval_seconds: int = Field(default=30, 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)
@@ -505,6 +505,17 @@ class ModelConcurrencySettings(BaseModel):
model_config = ConfigDict(extra="ignore")
class LocalWikiEncoderSettings(BaseModel):
"""Runtime override for the administrator-managed offline Wiki encoder."""
model_path: str = Field(
default="",
max_length=4096,
description="Absolute or project-relative directory containing the offline BGE-M3 ONNX model.",
)
model_config = ConfigDict(extra="ignore")
class SystemSettings(BaseModel):
"""Top-level system settings document."""
@@ -520,6 +531,7 @@ class SystemSettings(BaseModel):
agent_modules: AgentModuleSettings = Field(default_factory=AgentModuleSettings)
concurrency_monitor: ConcurrencyMonitorSettings = Field(default_factory=ConcurrencyMonitorSettings)
model_concurrency: ModelConcurrencySettings = Field(default_factory=ModelConcurrencySettings)
local_wiki_encoder: LocalWikiEncoderSettings = Field(default_factory=LocalWikiEncoderSettings)
llmwiki: LlmWikiRuntimeOverride = Field(default_factory=LlmWikiRuntimeOverride)
skill_knowledge_auto_sync: SkillKnowledgeAutoSyncSettings = Field(default_factory=SkillKnowledgeAutoSyncSettings)
knowledge_base_product_version: Literal["v1", "v2"] = Field(
@@ -4,7 +4,12 @@ from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import os
import subprocess
import sys
from datetime import UTC, datetime
from pathlib import Path
from time import monotonic
@@ -45,7 +50,13 @@ class StrictWikiEmbeddingClient:
self._owns_client = http_client is None
self._resolved_dimensions = config.dimensions
self._local_model = None
self._local_process: subprocess.Popen[str] | None = None
self._local_lock = asyncio.Lock()
self._local_state = "idle"
self._local_error: str | None = None
self._local_started_at: str | None = None
self._local_ready_at: str | None = None
self._local_warmup_task: asyncio.Task[None] | None = None
@property
def dimensions(self) -> int | None:
@@ -57,10 +68,35 @@ class StrictWikiEmbeddingClient:
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
async def close(self) -> None:
await self.reset_local_encoder()
if self._owns_client and self._client is not None:
await self._client.aclose()
self._client = None
async def reset_local_encoder(self, *, model_path: str | None = None) -> None:
"""Stop only the embedded worker and optionally switch its model path.
This is intentionally independent from the Gateway lifecycle: an
administrator can replace the offline model without restarting or
stopping the rest of DeerFlow.
"""
warmup = self._local_warmup_task
if warmup is not None and not warmup.done():
warmup.cancel()
await asyncio.gather(warmup, return_exceptions=True)
self._local_warmup_task = None
process = self._local_process
self._local_process = None
if process is not None:
await asyncio.to_thread(self._stop_local_process, process)
self._local_model = None
self._local_state = "idle"
self._local_error = None
self._local_started_at = None
self._local_ready_at = None
if model_path is not None:
self.config = self.config.model_copy(update={"local_model_path": model_path})
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(timeout=self.config.timeout_seconds)
@@ -71,7 +107,18 @@ class StrictWikiEmbeddingClient:
if not texts:
return []
async with self._local_lock:
return await asyncio.to_thread(self._embed_local, texts)
self._local_state = "starting"
self._local_started_at = self._local_started_at or datetime.now(UTC).isoformat()
try:
values = await asyncio.to_thread(self._embed_local, texts)
except Exception as exc:
self._local_state = "failed"
self._local_error = " ".join(str(exc).split())[:1000] or type(exc).__name__
raise
self._local_state = "ready"
self._local_error = None
self._local_ready_at = datetime.now(UTC).isoformat()
return values
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:
@@ -87,11 +134,36 @@ class StrictWikiEmbeddingClient:
def _embed_local(self, texts: list[str]) -> list[list[float]]:
if not self.config.enabled:
raise WikiEmbeddingError("本地编码已关闭", retryable=False)
model_path = self._validated_local_model_path()
if self.config.local_process_isolation:
return self._embed_local_process(texts, model_path)
return self._embed_local_in_process(texts, model_path)
def _validated_local_model_path(self) -> Path:
model_path = self._local_model_path()
if model_path is None:
raise WikiEmbeddingError(
"离线本地编码器目录未配置;请设置 local_model_path 或 DEERFLOW_BGE_M3_MODEL_PATH",
category="configuration",
retryable=False,
)
if not model_path.is_dir() or not (
(model_path / "onnx" / "model.onnx").is_file()
or (model_path / "model.onnx").is_file()
):
raise WikiEmbeddingError(
f"离线本地编码器目录无效或缺少 ONNX 模型:{model_path}",
category="configuration",
retryable=False,
)
return model_path
def _embed_local_in_process(self, texts: list[str], model_path: Path) -> list[list[float]]:
if self._local_model is None:
try:
from fastembed import TextEmbedding
except ImportError as exc:
raise WikiEmbeddingError("请安装本地编码依赖:uv sync --extra local-embedding", retryable=False) from exc
raise WikiEmbeddingError("标准后端依赖缺少 fastembed,请检查离线 wheelhouse 和安装结果", retryable=False) from exc
name = "BAAI/bge-m3" if self.config.model.lower() in {"bge-m3", "bge-embedding-m3"} else self.config.model
if name == "BAAI/bge-m3" and not any(m["model"] == name for m in TextEmbedding.list_supported_models()):
from fastembed.common.model_description import ModelSource, PoolingType
@@ -108,10 +180,14 @@ class StrictWikiEmbeddingClient:
size_in_gb=2.29,
)
cache_dir = Path(self.config.local_cache_dir).expanduser() if self.config.local_cache_dir else Path.home() / ".cache" / "deerflow" / "embeddings"
options = {"model_name": name, "threads": self.config.local_threads, "cache_dir": str(cache_dir), "providers": ["CPUExecutionProvider"]}
if self.config.local_model_path:
options["specific_model_path"] = str(Path(self.config.local_model_path).expanduser())
options["local_files_only"] = True
options = {
"model_name": name,
"threads": self.config.local_threads,
"cache_dir": str(cache_dir),
"providers": ["CPUExecutionProvider"],
"specific_model_path": str(model_path),
"local_files_only": True,
}
self._local_model = TextEmbedding(**options)
values = []
for vector in self._local_model.embed(texts, batch_size=min(self.config.batch_size, 4)):
@@ -123,6 +199,146 @@ class StrictWikiEmbeddingClient:
raise WikiEmbeddingError("本地编码返回数量不符", retryable=False)
return values
def _embed_local_process(self, texts: list[str], model_path: Path) -> list[list[float]]:
process = self._local_process
if process is None or process.poll() is not None:
process = self._start_local_process(model_path)
self._local_process = process
if process.stdin is None or process.stdout is None:
raise WikiEmbeddingError("本地编码器子进程通信通道不可用", retryable=False)
request_id = hashlib.sha256(f"{monotonic()}:{len(texts)}".encode()).hexdigest()[:16]
try:
process.stdin.write(
json.dumps({"id": request_id, "texts": texts}, ensure_ascii=False) + "\n"
)
process.stdin.flush()
response = self._read_local_process_message(process, request_id=request_id)
except (BrokenPipeError, OSError, ValueError, json.JSONDecodeError) as exc:
self._stop_local_process(process)
self._local_process = None
raise WikiEmbeddingError(
f"本地编码器子进程异常退出:{str(exc)[:500]}",
category="local_process",
retryable=True,
) from exc
if response.get("status") != "ok":
error = str(response.get("error") or "本地编码失败")[:1000]
if process.poll() is not None:
self._local_process = None
raise WikiEmbeddingError(error, category="local_process", retryable=False)
raw_vectors = response.get("vectors")
if not isinstance(raw_vectors, list) or len(raw_vectors) != len(texts):
raise WikiEmbeddingError("本地编码返回数量不符", retryable=False)
values: list[list[float]] = []
for raw in raw_vectors:
normalized = normalize_vector(raw, dimensions=self._resolved_dimensions)
if self._resolved_dimensions is None:
self._resolved_dimensions = int(normalized.size)
values.append(normalized.tolist())
return values
def _start_local_process(self, model_path: Path) -> subprocess.Popen[str]:
name = "BAAI/bge-m3" if self.config.model.lower() in {"bge-m3", "bge-embedding-m3"} else self.config.model
cache_dir = (
Path(self.config.local_cache_dir).expanduser()
if self.config.local_cache_dir
else Path.home() / ".cache" / "deerflow" / "embeddings"
)
command = [
sys.executable,
"-m",
"deerflow.integrations.weknora.local_index.embedding_worker",
"--model-name",
name,
"--model-path",
str(model_path),
"--cache-dir",
str(cache_dir),
"--threads",
str(self.config.local_threads),
"--batch-size",
str(min(self.config.batch_size, 4)),
]
environment = os.environ.copy()
environment.update(
{
"HF_HUB_OFFLINE": "1",
"TRANSFORMERS_OFFLINE": "1",
"HF_DATASETS_OFFLINE": "1",
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1",
}
)
creation_flags = 0
if os.name == "nt":
creation_flags = int(getattr(subprocess, "CREATE_NO_WINDOW", 0)) | int(
getattr(subprocess, "BELOW_NORMAL_PRIORITY_CLASS", 0)
)
try:
process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
encoding="utf-8",
bufsize=1,
env=environment,
creationflags=creation_flags,
)
response = self._read_local_process_message(process, ready=True)
except (OSError, ValueError, json.JSONDecodeError) as exc:
if "process" in locals():
self._stop_local_process(process)
raise WikiEmbeddingError(
f"无法启动本地编码器子进程:{str(exc)[:500]}",
category="local_process",
retryable=False,
) from exc
if response.get("status") != "ready":
error = str(response.get("error") or "本地编码器初始化失败")[:1000]
self._stop_local_process(process)
raise WikiEmbeddingError(error, category="local_process", retryable=False)
return process
@staticmethod
def _read_local_process_message(
process: subprocess.Popen[str],
*,
request_id: str | None = None,
ready: bool = False,
) -> dict[str, object]:
if process.stdout is None:
raise ValueError("本地编码器没有输出通道")
for _ in range(20):
line = process.stdout.readline()
if not line:
raise ValueError(f"本地编码器已退出(exit={process.poll()})")
payload = json.loads(line)
if not isinstance(payload, dict):
continue
if ready and payload.get("status") in {"ready", "error"}:
return payload
if request_id is not None and payload.get("id") == request_id:
return payload
raise ValueError("本地编码器返回了无法识别的响应")
@staticmethod
def _stop_local_process(process: subprocess.Popen[str]) -> None:
if process.poll() is not None:
return
try:
if process.stdin is not None:
process.stdin.write('{"command":"shutdown"}\n')
process.stdin.flush()
process.wait(timeout=2)
except Exception:
process.terminate()
try:
process.wait(timeout=2)
except Exception:
process.kill()
async def _embed_batch(self, texts: list[str]) -> list[list[float]]:
url = self.config.base_url.rstrip("/") + "/embeddings"
headers = {"Content-Type": "application/json"}
@@ -185,3 +401,80 @@ class StrictWikiEmbeddingClient:
async def embed_query(self, text: str) -> list[float]:
result = await self.embed_texts([text])
return result[0]
def local_encoder_status(self) -> dict[str, object]:
running = self._local_warmup_task is not None and not self._local_warmup_task.done()
process_ready = self._local_process is not None and self._local_process.poll() is None
process_failed = self._local_process is not None and not process_ready
state = (
"starting"
if running
else "ready"
if (self._local_model is not None or process_ready)
else "failed"
if process_failed
else self._local_state
)
last_error = self._local_error
if process_failed and not last_error:
last_error = f"本地编码器子进程已退出(exit={self._local_process.poll()})"
model_path = self._local_model_path()
model_files_ready = bool(
model_path
and model_path.is_dir()
and ((model_path / "onnx" / "model.onnx").is_file() or (model_path / "model.onnx").is_file())
)
return {
"provider": self.config.provider,
"model": self.config.model,
"dimensions": self.dimensions,
"state": state,
"running": running,
"last_error": last_error,
"started_at": self._local_started_at,
"ready_at": self._local_ready_at,
"offline_only": self.config.provider == "local",
"model_path_configured": model_path is not None,
"model_path_exists": bool(model_path and model_path.is_dir()),
"model_files_ready": model_files_ready,
"process_isolation": self.config.local_process_isolation,
"model_path": str(model_path) if model_path is not None else "",
}
def _local_model_path(self) -> Path | None:
raw = self.config.local_model_path.strip() or os.environ.get(
"DEERFLOW_BGE_M3_MODEL_PATH", ""
).strip()
if not raw:
return None
path = Path(raw).expanduser()
return path.resolve() if path.is_absolute() else (Path.cwd() / path).resolve()
def start_local_encoder(self) -> dict[str, object]:
"""Warm the embedded CPU encoder without coupling it to app startup."""
if self.config.provider != "local":
raise ValueError("当前 embedding provider 不是 local")
if not self.config.enabled:
raise ValueError("本地编码器未启用")
process_ready = self._local_process is not None and self._local_process.poll() is None
if self._local_model is not None or process_ready:
self._local_state = "ready"
return self.local_encoder_status()
if self._local_warmup_task is None or self._local_warmup_task.done():
self._local_state = "starting"
self._local_error = None
self._local_started_at = datetime.now(UTC).isoformat()
self._local_warmup_task = asyncio.create_task(
self._warm_local_encoder(),
name="llmwiki-local-encoder-warmup",
)
return self.local_encoder_status()
async def _warm_local_encoder(self) -> None:
try:
await self.embed_query("本地编码器启动检查")
except asyncio.CancelledError:
self._local_state = "idle"
raise
except Exception:
logger.exception("Local Wiki encoder warmup failed model=%s", self.config.model)
@@ -0,0 +1,98 @@
"""Offline FastEmbed worker isolated from the DeerFlow gateway process.
The protocol is UTF-8 JSON Lines over stdin/stdout. Model weights must already
exist on disk; every offline flag is enforced before FastEmbed is imported.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
def _write(payload: dict[str, object]) -> None:
sys.stdout.write(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n")
sys.stdout.flush()
def _build_model(args: argparse.Namespace):
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["TRANSFORMERS_OFFLINE"] = "1"
os.environ["HF_DATASETS_OFFLINE"] = "1"
from fastembed import TextEmbedding
if args.model_name == "BAAI/bge-m3" and not any(
item["model"] == args.model_name for item in TextEmbedding.list_supported_models()
):
from fastembed.common.model_description import ModelSource, PoolingType
TextEmbedding.add_custom_model(
model=args.model_name,
pooling=PoolingType.CLS,
normalization=True,
dim=1024,
sources=ModelSource(hf="BAAI/bge-m3"),
model_file="onnx/model.onnx",
additional_files=["onnx/model.onnx_data", "onnx/Constant_7_attr__value"],
license="mit",
size_in_gb=2.29,
)
return TextEmbedding(
model_name=args.model_name,
threads=args.threads,
cache_dir=args.cache_dir,
providers=["CPUExecutionProvider"],
specific_model_path=args.model_path,
local_files_only=True,
)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--model-name", required=True)
parser.add_argument("--model-path", required=True)
parser.add_argument("--cache-dir", required=True)
parser.add_argument("--threads", type=int, default=2)
parser.add_argument("--batch-size", type=int, default=2)
args = parser.parse_args()
model_path = Path(args.model_path)
if not model_path.is_dir():
_write({"status": "error", "error": f"离线模型目录不存在:{model_path}"})
return 2
try:
model = _build_model(args)
except Exception as exc: # noqa: BLE001
_write({"status": "error", "error": f"离线模型加载失败:{type(exc).__name__}: {exc}"})
return 2
_write({"status": "ready"})
for line in sys.stdin:
request: dict[str, object] = {}
try:
request = json.loads(line)
if request.get("command") == "shutdown":
return 0
request_id = str(request.get("id") or "")
texts = request.get("texts")
if not request_id or not isinstance(texts, list) or not all(isinstance(value, str) for value in texts):
raise ValueError("编码请求格式无效")
vectors = [
value.tolist()
for value in model.embed(texts, batch_size=max(1, min(args.batch_size, 4)))
]
_write({"id": request_id, "status": "ok", "vectors": vectors})
except Exception as exc: # noqa: BLE001
_write(
{
"id": str(request.get("id") or "") if isinstance(request, dict) else "",
"status": "error",
"error": f"本地编码失败:{type(exc).__name__}: {exc}",
}
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -163,7 +163,10 @@ class WikiSyncService:
error=f"{failed} page(s) failed" if failed else None,
)
result = {"mapping_id": mapping_id, "status": "partial" if failed else "completed", "remote_pages": remote_count, "changed_pages": changed, "deleted_pages": deleted, "failed_pages": failed}
if not failed and remote_count and notify and self.on_completed:
# Notify even when the remote library is now empty. The global
# assistant base must then remove stale contributions from this
# source instead of retaining deleted Wiki pages forever.
if not failed and notify and self.on_completed:
result["assistant_sync"] = await self.on_completed(mapping)
return result
except asyncio.CancelledError:
@@ -158,6 +158,11 @@ class SkillKnowledgeRepository:
binding.remote_business_key = target.get("remote_business_key")
binding.status = "queued"
binding.active = True
# The current manual submitter owns the relationship UI
# from this point onward. Target write authorization is
# enforced by the router before this method is called.
if trigger == "manual":
binding.created_by = created_by
binding.updated_at = now
item = SkillKnowledgeSyncItemRow(
id=str(uuid4()),
@@ -187,7 +192,14 @@ class SkillKnowledgeRepository:
await session.commit()
return result
async def list_bindings(self, *, skill_names: list[str] | None = None, target_id: str | None = None, include_detached: bool = False) -> list[dict[str, Any]]:
async def list_bindings(
self,
*,
skill_names: list[str] | None = None,
target_id: str | None = None,
include_detached: bool = False,
created_by: str | None = None,
) -> list[dict[str, Any]]:
stmt = select(SkillKnowledgeBindingRow)
if skill_names:
stmt = stmt.where(SkillKnowledgeBindingRow.skill_name.in_(skill_names))
@@ -195,6 +207,8 @@ class SkillKnowledgeRepository:
stmt = stmt.where(SkillKnowledgeBindingRow.target_id == target_id)
if not include_detached:
stmt = stmt.where(SkillKnowledgeBindingRow.active.is_(True))
if created_by is not None:
stmt = stmt.where(SkillKnowledgeBindingRow.created_by == created_by)
stmt = stmt.order_by(SkillKnowledgeBindingRow.updated_at.desc())
async with self._sf() as session:
return [_dict(row) for row in (await session.execute(stmt)).scalars()]
@@ -247,10 +261,33 @@ class SkillKnowledgeRepository:
result["items"] = [_dict(item) for item in items]
return result
async def list_jobs(self, *, limit: int = 50) -> list[dict[str, Any]]:
async def list_jobs(self, *, limit: int = 50, created_by: str | None = None) -> list[dict[str, Any]]:
stmt = select(SkillKnowledgeSyncJobRow).order_by(SkillKnowledgeSyncJobRow.created_at.desc()).limit(limit)
if created_by is not None:
stmt = stmt.where(SkillKnowledgeSyncJobRow.created_by == created_by)
async with self._sf() as session:
return [_dict(row) for row in (await session.execute(stmt)).scalars()]
jobs = list((await session.execute(stmt)).scalars())
if not jobs:
return []
job_ids = [row.id for row in jobs]
item_rows = list(
(
await session.execute(
select(SkillKnowledgeSyncItemRow)
.where(SkillKnowledgeSyncItemRow.job_id.in_(job_ids))
.order_by(SkillKnowledgeSyncItemRow.created_at)
)
).scalars()
)
items_by_job: dict[str, list[dict[str, Any]]] = {job_id: [] for job_id in job_ids}
for item in item_rows:
items_by_job[item.job_id].append(_dict(item))
result = []
for job in jobs:
data = _dict(job)
data["items"] = items_by_job[job.id]
result.append(data)
return result
async def list_queued_items(self, *, limit: int = 20) -> list[dict[str, Any]]:
now = _now()
@@ -25,13 +25,13 @@ dependencies = [
"hindsight-client>=0.4.22",
"asyncmy>=0.2.11",
"openpyxl>=3.1.5",
"fastembed>=0.7,<0.9",
]
[project.optional-dependencies]
postgres = ["deerflow-harness[postgres]"]
mysql = ["deerflow-harness[mysql]"]
gauss = ["deerflow-harness[gauss]"]
local-embedding = ["fastembed>=0.7,<0.9"]
[dependency-groups]
dev = [
@@ -3,6 +3,7 @@ import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.llmwiki_rag import _source_from_assistant_result, _source_from_result
from deerflow.persistence.assistant_knowledge import AssistantKnowledgeRepository
from deerflow.persistence.assistant_knowledge.model import (
AssistantKnowledgeEntityContributionRow,
@@ -194,3 +195,264 @@ async def test_imported_vectors_are_reused_for_assistant_search(
query="这个查询不靠关键词",
embedding_client=FakeEmbeddingClient(),
) == []
@pytest.mark.asyncio
async def test_reimport_retires_pages_removed_from_source_snapshot(
repository: AssistantKnowledgeRepository,
) -> None:
base = await repository.initialize(created_by="admin")
common = {
"source_type": "weknora",
"source_key": "kb-refresh",
"source_name": "自动同步普通库",
"trigger": "vectorization_completed",
"created_by": "admin",
"mapping_id": "kb-refresh",
}
await repository.import_package(
package={
"source": {"type": "weknora", "knowledge_base_id": "kb-refresh"},
"wiki_pages": [
{"slug": "wiki/kept", "title": "保留页", "content": "保留内容"},
{"slug": "wiki/removed", "title": "删除页", "content": "旧内容"},
],
},
**common,
)
await repository.import_package(
package={
"source": {"type": "weknora", "knowledge_base_id": "kb-refresh"},
"wiki_pages": [
{"slug": "wiki/kept", "title": "保留页", "content": "新内容"},
],
},
**common,
)
pages = await repository.list_pages(base_id=base["id"], include_chunks=True)
assert [page["slug"] for page in pages] == ["wiki/kept"]
assert pages[0]["content_markdown"] == "新内容"
@pytest.mark.asyncio
async def test_custom_base_can_be_renamed_and_deleted_without_affecting_global_base(
repository: AssistantKnowledgeRepository,
) -> None:
global_base = await repository.initialize(created_by="admin")
custom_base = await repository.create_base(
created_by="admin",
name="待编辑助手库",
description="旧描述",
)
await repository.import_package(
package={
"source": {"type": "weknora"},
"wiki_pages": [
{"slug": "wiki/delete-test", "title": "删除测试", "content": "保留在总库中的内容"}
],
"entities": [{"local_id": "e1", "type": "concept", "name": "删除测试实体"}],
"relations": [],
},
source_type="weknora",
source_key="kb-delete-test",
source_name="删除测试普通库",
trigger="manual_from_weknora",
created_by="admin",
mapping_id="kb-delete-test",
base_id=custom_base["id"],
)
updated = await repository.update_base(
custom_base["id"],
name="已编辑助手库",
description="新描述",
)
assert updated is not None
assert updated["name"] == "已编辑助手库"
assert updated["description"] == "新描述"
assert await repository.delete_base(custom_base["id"])
assert await repository.get_base(custom_base["id"]) is None
assert all(base["id"] != custom_base["id"] for base in await repository.list_bases())
assert await repository.list_pages(base_id=global_base["id"], query="保留在总库")
with pytest.raises(ValueError, match="不能删除"):
await repository.delete_base(global_base["id"])
@pytest.mark.asyncio
async def test_global_base_aligns_same_entity_title_and_searches_knowledge_body(
repository: AssistantKnowledgeRepository,
) -> None:
base = await repository.initialize(created_by="admin")
first_package = {
"source": {"provider": "weknora", "knowledge_base_id": "kb-one", "knowledge_base_name": "人员库"},
"wiki_pages": [
{
"slug": "people/zhang-san",
"title": "张 三",
"page_type": "article",
"content": "张三负责设备巡检。",
"summary": "摘要专用词:月度综述。",
}
],
"entities": [
{
"local_id": "one",
"type": "人物",
"name": "张 三",
"aliases": ["Zhang San"],
"source_wiki_slug": "people/zhang-san",
}
],
}
second_package = {
"source": {"provider": "weknora", "knowledge_base_id": "kb-two", "knowledge_base_name": "职责库"},
"wiki_pages": [
{
"slug": "entity/person-001",
"title": "Zhang San",
"page_type": "concept",
"content": "张三负责系统故障处置。",
"summary": "另一条摘要。",
}
],
"entities": [
{
"local_id": "two",
"type": "person",
"name": "张三",
"aliases": ["Zhang San"],
"source_wiki_slug": "entity/person-001",
}
],
}
for package, source_key in ((first_package, "kb-one"), (second_package, "kb-two")):
await repository.import_package(
package=package,
source_type="weknora",
source_key=source_key,
source_name=source_key,
trigger="vectorization_completed",
created_by="admin",
)
pages = await repository.list_pages(base_id=base["id"])
assert len(pages) == 1
assert "张三负责设备巡检。" in pages[0]["content_markdown"]
assert "张三负责系统故障处置。" in pages[0]["content_markdown"]
assert len(pages[0]["origins"]) == 2
assert await repository.search_pages(
base_ids=[base["id"]],
query="月度综述",
embedding_client=None,
) == []
results = await repository.search_pages(
base_ids=[base["id"]],
query="系统故障处置",
embedding_client=None,
)
assert len(results) == 1
assert "系统故障处置" in results[0]["matched_section"]["content"]
assert "月度综述" not in results[0]["matched_section"]["content"]
async with repository._sf() as session:
entities = (
await session.execute(
select(AssistantKnowledgeEntityRow).where(
AssistantKnowledgeEntityRow.base_id == base["id"],
AssistantKnowledgeEntityRow.status == "active",
)
)
).scalars().all()
assert len(entities) == 1
contributions = (
await session.execute(
select(AssistantKnowledgeEntityContributionRow).where(
AssistantKnowledgeEntityContributionRow.entity_id == entities[0].id
)
)
).scalars().all()
assert len(contributions) == 2
@pytest.mark.asyncio
async def test_uploaded_package_reuses_embedded_weknora_source_identity(
repository: AssistantKnowledgeRepository,
) -> None:
base = await repository.initialize(created_by="admin")
for upload_no, content in ((1, "旧知识"), (2, "新知识")):
job = await repository.create_queued_import_job(
source_type="weknora",
source_key=f"upload:{upload_no}",
source_name=f"upload-{upload_no}.jsonl",
trigger="manual_package_upload",
created_by="admin",
base_id=base["id"],
)
await repository.import_package_for_job(
job["id"],
package={
"source": {
"provider": "weknora",
"knowledge_base_id": "stable-kb-id",
"knowledge_base_name": "同一个普通知识库",
},
"wiki_pages": [
{
"slug": "wiki/stable",
"title": "稳定实体",
"content": content,
}
],
},
created_by="admin",
)
active_sources = await repository.list_sources(base_id=base["id"])
assert len(active_sources) == 1
assert active_sources[0]["source_key"] == "stable-kb-id"
pages = await repository.list_pages(base_id=base["id"])
assert len(pages) == 1
assert pages[0]["content_markdown"] == "新知识"
def test_assistant_citation_uses_matched_knowledge_instead_of_summary() -> None:
source = _source_from_assistant_result(
{
"id": "page-1",
"title": "设备知识",
"summary": "只是一段摘要",
"content_markdown": "完整页面正文",
"matched_section": {
"content": "实际命中的知识片段:设备过热时先断电。",
},
"score": 0.91,
},
1,
)
assert source["content"] == "实际命中的知识片段:设备过热时先断电。"
assert source["snippet"] == "实际命中的知识片段:设备过热时先断电。"
assert "只是一段摘要" not in source["snippet"]
def test_regular_wiki_citation_does_not_fall_back_to_summary() -> None:
source = _source_from_result(
{
"kind": "wiki_page",
"wiki_page_id": "wiki-1",
"title": "设备知识",
"summary": "只是一段摘要",
"content": "实际知识正文",
"matched_sections": [],
},
{"id": "kb-1", "name": "普通知识库"},
1,
)
assert source["content"] == "实际知识正文"
assert source["snippet"] == "实际知识正文"
assert "只是一段摘要" not in source["snippet"]
@@ -199,7 +199,7 @@ async def test_fingerprint_change_automatically_rebuilds_entire_mapping() -> Non
@pytest.mark.asyncio
async def test_auto_sync_scheduler_waits_before_first_scan(monkeypatch) -> None:
async def test_auto_sync_scheduler_scans_immediately_then_waits(monkeypatch) -> None:
calls: list[str] = []
async def fake_sleep(seconds: float) -> None:
@@ -208,6 +208,8 @@ async def test_auto_sync_scheduler_waits_before_first_scan(monkeypatch) -> None:
async def fake_sync_once(app) -> None:
calls.append("sync")
if calls.count("sync") > 1:
raise asyncio.CancelledError
monkeypatch.setattr(llmwiki_index_scheduler.asyncio, "sleep", fake_sleep)
monkeypatch.setattr(llmwiki_index_scheduler, "_sync_once", fake_sync_once)
@@ -224,7 +226,14 @@ async def test_auto_sync_scheduler_waits_before_first_scan(monkeypatch) -> None:
with pytest.raises(asyncio.CancelledError):
await llmwiki_index_scheduler.local_wiki_index_scheduler_loop(app)
assert calls == ["sleep:120"]
assert calls == ["sync", "sleep:120"]
def test_local_wiki_index_defaults_to_automatic_sync() -> None:
config = LocalWikiIndexConfig()
assert config.auto_sync is True
assert config.sync_interval_seconds == 30
def test_local_wiki_index_accepts_legacy_sync_flag() -> None:
@@ -1,15 +1,18 @@
import base64
import json
import sys
from io import BytesIO
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi import FastAPI, UploadFile
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.routers import wiki_packages
from deerflow.assistant_knowledge.archive import PackageArchive
from deerflow.assistant_knowledge.package_io import write_package
from deerflow.config.llmwiki_config import LocalWikiEmbeddingConfig
from deerflow.integrations.weknora.local_index import embedding as embedding_module
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
@@ -146,7 +149,7 @@ async def test_metadata_vectors_and_links_survive_roundtrip_without_encoding(tmp
@pytest.mark.asyncio
async def test_local_encoder_is_bounded_cpu_and_does_not_need_http(monkeypatch):
async def test_local_encoder_is_bounded_cpu_and_does_not_need_http(monkeypatch, tmp_path):
observed = {}
class LocalModel:
@@ -162,13 +165,115 @@ async def test_local_encoder_is_bounded_cpu_and_does_not_need_http(monkeypatch):
return [[1.0, *([0.0] * 1023)] for _ in texts]
monkeypatch.setitem(sys.modules, "fastembed", SimpleNamespace(TextEmbedding=LocalModel))
config = LocalWikiEmbeddingConfig(provider="local", model="bge-embedding-m3", dimensions=1024, batch_size=2, local_threads=2)
(tmp_path / "onnx").mkdir()
(tmp_path / "onnx" / "model.onnx").touch()
config = LocalWikiEmbeddingConfig(
provider="local",
model="bge-embedding-m3",
dimensions=1024,
batch_size=2,
local_threads=2,
local_model_path=str(tmp_path),
local_process_isolation=False,
)
client = StrictWikiEmbeddingClient(config)
assert len(await client.embed_query("本地查询")) == 1024
assert observed["model_name"] == "BAAI/bge-m3"
assert observed["providers"] == ["CPUExecutionProvider"]
assert observed["local_files_only"] is True
assert observed["specific_model_path"] == str(tmp_path)
assert observed["batch_size"] == observed["threads"] == 2
assert client._client is None
assert client.local_encoder_status()["state"] == "ready"
assert client.local_encoder_status()["model_files_ready"] is True
cold_client = StrictWikiEmbeddingClient(config)
status = cold_client.start_local_encoder()
assert status["state"] == "starting"
assert status["running"] is True
await cold_client._local_warmup_task
assert cold_client.local_encoder_status()["state"] == "ready"
monkeypatch.delenv("DEERFLOW_BGE_M3_MODEL_PATH", raising=False)
missing_path = StrictWikiEmbeddingClient(
LocalWikiEmbeddingConfig(provider="local", model="bge-embedding-m3", dimensions=1024),
)
missing_path.start_local_encoder()
await missing_path._local_warmup_task
assert missing_path.local_encoder_status()["state"] == "failed"
assert "目录未配置" in str(missing_path.local_encoder_status()["last_error"])
@pytest.mark.asyncio
async def test_local_encoder_uses_offline_isolated_worker(monkeypatch, tmp_path):
(tmp_path / "onnx").mkdir()
(tmp_path / "onnx" / "model.onnx").touch()
observed: dict = {}
class FakeStdout:
def __init__(self):
self.lines = [json.dumps({"status": "ready"}) + "\n"]
def readline(self):
return self.lines.pop(0) if self.lines else ""
class FakeProcess:
def __init__(self):
self.stdout = FakeStdout()
self.returncode = None
self.stdin = SimpleNamespace(write=self.write, flush=lambda: None)
def write(self, line):
payload = json.loads(line)
if payload.get("command") == "shutdown":
self.returncode = 0
return
self.stdout.lines.append(
json.dumps(
{
"id": payload["id"],
"status": "ok",
"vectors": [[1.0, *([0.0] * 1023)] for _ in payload["texts"]],
}
)
+ "\n"
)
def poll(self):
return self.returncode
def wait(self, timeout=None):
return self.returncode
def terminate(self):
self.returncode = -15
def kill(self):
self.returncode = -9
process = FakeProcess()
def fake_popen(command, **kwargs):
observed.update({"command": command, **kwargs})
return process
monkeypatch.setattr(embedding_module.subprocess, "Popen", fake_popen)
client = StrictWikiEmbeddingClient(
LocalWikiEmbeddingConfig(
provider="local",
model="bge-embedding-m3",
dimensions=1024,
local_model_path=str(tmp_path),
)
)
assert len(await client.embed_query("隔离编码")) == 1024
assert "deerflow.integrations.weknora.local_index.embedding_worker" in observed["command"]
assert observed["env"]["HF_HUB_OFFLINE"] == "1"
assert observed["env"]["PYTHONIOENCODING"] == "utf-8"
assert client.local_encoder_status()["process_isolation"] is True
await client.close()
assert process.poll() == 0
@pytest.mark.asyncio
@@ -178,7 +283,7 @@ async def test_builtin_skill_without_ownership_record_can_be_distilled(monkeypat
from app.gateway.routers import skill_knowledge
async def admin(_):
return "admin"
return "admin", True
class Mappings:
async def get_authorized(self, *args, **kwargs):
@@ -193,11 +298,61 @@ async def test_builtin_skill_without_ownership_record_can_be_distilled(monkeypat
app.state.llmwiki_store = Mappings()
app.state.skill_knowledge_store = Jobs()
# Deliberately no skill_store ownership row: built-ins live on disk.
monkeypatch.setattr(skill_knowledge, "_require_admin", admin)
monkeypatch.setattr(skill_knowledge, "_actor", admin)
monkeypatch.setattr(skill_knowledge, "_skill_directory", lambda *args: tmp_path)
body = skill_knowledge.CreateSyncJobRequest(skill_names=["builtin"], targets=[{"target_type": "weknora", "target_mode": "wiki", "target_id": "mapping"}])
job = await skill_knowledge.create_sync_job(Request({"type": "http", "app": app}), body)
assert job["skill_names"] == ["builtin"]
@pytest.mark.asyncio
async def test_regular_user_can_distill_visible_skill_only_to_writable_wiki(monkeypatch, tmp_path):
from starlette.requests import Request
from app.gateway.routers import skill_knowledge
async def regular_user(_):
return "user-1", False
class Skills:
async def get_visible(self, name, user_id):
assert user_id == "user-1"
return {"name": name} if name == "visible-skill" else None
async def list_favorite_skill_names(self, user_id):
assert user_id == "user-1"
return []
class Mappings:
async def get_authorized(self, mapping_id, user_id, *, write, is_admin):
assert (mapping_id, user_id, write, is_admin) == ("mapping", "user-1", True, False)
return {"name": "我的 Wiki", "weknora_id": "remote"}
class Jobs:
async def create_job(self, **kwargs):
return kwargs
app = FastAPI()
app.state.config = None
app.state.skill_store = Skills()
app.state.llmwiki_store = Mappings()
app.state.skill_knowledge_store = Jobs()
monkeypatch.setattr(skill_knowledge, "_actor", regular_user)
monkeypatch.setattr(skill_knowledge, "_skill_directory", lambda *args: tmp_path)
monkeypatch.setattr(
skill_knowledge,
"get_or_new_skill_storage",
lambda **_: SimpleNamespace(get_skills_root_path=lambda: tmp_path),
)
body = skill_knowledge.CreateSyncJobRequest(
skill_names=["visible-skill"],
targets=[{"target_type": "weknora", "target_mode": "wiki", "target_id": "mapping"}],
review_mode="required",
)
job = await skill_knowledge.create_sync_job(Request({"type": "http", "app": app}), body)
assert job["created_by"] == "user-1"
assert job["review_mode"] == "off"
@@ -205,3 +360,70 @@ def test_short_wiki_summary_and_body_still_have_a_vector_section():
sections = chunk_wiki_page({"title": "风扇", "summary": "通过气流帮助散热。", "content_md": "# 风扇\n用于降低设备温度。"}, max_chars=1000, overlap=100, min_chars=80, title_prefix_enabled=True)
assert len(sections) == 1
assert "降低设备温度" in sections[0].embedding_text
@pytest.mark.asyncio
async def test_admin_can_upload_and_activate_offline_encoder(monkeypatch, tmp_path):
from starlette.requests import Request
from app.gateway.routers import llmwiki_index
from deerflow.config.system_settings import SystemSettings
async def admin(_):
return "admin", True
class Embedding:
def __init__(self):
self.model_path = ""
async def reset_local_encoder(self, *, model_path=None):
if model_path is not None:
self.model_path = model_path
def local_encoder_status(self):
return {
"provider": "local",
"model": "bge-embedding-m3",
"state": "idle",
"model_path": self.model_path,
"model_files_ready": bool(self.model_path),
}
saved = []
monkeypatch.setattr(llmwiki_index, "_actor", admin)
monkeypatch.setattr(llmwiki_index, "runtime_home", lambda: tmp_path)
monkeypatch.setattr(llmwiki_index, "load_system_settings", SystemSettings)
monkeypatch.setattr(llmwiki_index, "save_system_settings", lambda value: saved.append(value) or value)
embedding = Embedding()
app = FastAPI()
app.state.config = SimpleNamespace(
llmwiki=SimpleNamespace(
local_wiki_index=SimpleNamespace(embedding=SimpleNamespace(provider="local"))
)
)
app.state.llmwiki_embedding = embedding
request = Request({"type": "http", "headers": [], "app": app})
files = [
UploadFile(filename="model.onnx", file=BytesIO(b"onnx")),
UploadFile(filename="config.json", file=BytesIO(b"{}")),
]
result = await llmwiki_index.upload_local_encoder(
request,
files=files,
paths=["onnx/model.onnx", "config.json"],
)
active = tmp_path / "models" / "bge-m3"
assert (active / "onnx" / "model.onnx").read_bytes() == b"onnx"
assert result["uploaded_files"] == 2
assert saved[-1].local_wiki_encoder.model_path == str(active.resolve())
assert embedding.model_path == str(active.resolve())
def test_encoder_upload_rejects_parent_traversal():
from app.gateway.routers import llmwiki_index
with pytest.raises(Exception) as exc_info:
llmwiki_index._safe_relative_upload_path("../model.onnx")
assert getattr(exc_info.value, "status_code", None) == 422