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

+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(