feat: improve knowledge sync and entity alignment
This commit is contained in:
1 parent
7dbac9cc56
commit
dea4697ea0
38 files changed
+3742
-634
No files matched your search
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+158
-226
@@ -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" />
|
||||
|
||||
+130
-1
@@ -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();
|
||||
|
||||
+221
@@ -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>
|
||||
);
|
||||
}
|
||||
+24
-2
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user