281 lines
15 KiB
TypeScript
281 lines
15 KiB
TypeScript
import { CheckCircle2, Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { toast } from "sonner";
|
||
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
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 { useAllSkillsAdmin, useSkills } from "@/core/skills/hooks";
|
||
import { useLlmWikiKnowledgeBases, useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||
import {
|
||
type SkillKnowledgeTarget,
|
||
useSkillKnowledgeActions,
|
||
useSkillKnowledgeBindings,
|
||
useSkillKnowledgeJobs,
|
||
} from "@/strategy-components/api/skill-knowledge";
|
||
|
||
const PHASE_LABELS: Record<string, string> = {
|
||
scanning: "扫描知识文件",
|
||
parsing: "解析资料",
|
||
extracting: "抽取实体关系",
|
||
awaiting_review: "等待审核",
|
||
merging: "合并实体关系",
|
||
writing_wiki: "写入 Wiki",
|
||
writing_sources: "上传知识资料",
|
||
generating_wiki: "分析并生成分类 Wiki",
|
||
vectorizing_wiki: "Wiki 向量化",
|
||
syncing_assistant: "同步知识梳理总库",
|
||
activating: "激活新版本",
|
||
completed: "完成",
|
||
};
|
||
|
||
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 runtime = useLlmWikiRuntime();
|
||
const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora");
|
||
const bindings = useSkillKnowledgeBindings(undefined, !adminMode);
|
||
const jobs = useSkillKnowledgeJobs(!adminMode);
|
||
const actions = useSkillKnowledgeActions();
|
||
|
||
const targets = useMemo<SkillKnowledgeTarget[]>(() => {
|
||
return (knowledgeBases.data ?? [])
|
||
.filter((kb) => {
|
||
const strategy = kb.indexing_strategy ?? {};
|
||
const capabilities = kb.capabilities ?? {};
|
||
const wikiEnabled =
|
||
kb.type === "wiki" ||
|
||
kb.wiki_index_enabled === true ||
|
||
strategy.wiki_enabled === true ||
|
||
capabilities.wiki_enabled === true ||
|
||
(kb.wiki_config && Object.keys(kb.wiki_config).length > 0);
|
||
return kb.can_write && kb.remote_status !== "missing" && !kb.is_conversation_deposit && kb.type !== "faq" && wikiEnabled;
|
||
})
|
||
.map((kb) => ({
|
||
target_type: "weknora",
|
||
target_mode: "wiki",
|
||
target_id: kb.id,
|
||
target_name: kb.name,
|
||
remote_business_key: `weknora:kb:${kb.id}`,
|
||
}));
|
||
}, [knowledgeBases.data]);
|
||
|
||
const filteredSkills = useMemo(() => {
|
||
const needle = skillQuery.trim().toLowerCase();
|
||
if (!needle) return liveSkills;
|
||
return liveSkills.filter((skill) => {
|
||
const tagText = (skill.tags ?? []).map((tag) => tag.name).join(" ");
|
||
const haystack = [
|
||
skill.name,
|
||
skill.name_zh,
|
||
skill.description,
|
||
skill.category,
|
||
tagText,
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n")
|
||
.toLowerCase();
|
||
return haystack.includes(needle);
|
||
});
|
||
}, [liveSkills, skillQuery]);
|
||
|
||
const filteredSkillNames = useMemo(() => filteredSkills.map((skill) => skill.name), [filteredSkills]);
|
||
const allFilteredSelected = filteredSkillNames.length > 0 && filteredSkillNames.every((name) => selectedSkills.includes(name));
|
||
|
||
const toggleFilteredSkills = () => {
|
||
if (!filteredSkillNames.length) return;
|
||
setSelectedSkills((current) => {
|
||
if (allFilteredSelected) {
|
||
const filtered = new Set(filteredSkillNames);
|
||
return current.filter((name) => !filtered.has(name));
|
||
}
|
||
return [...current, ...filteredSkillNames.filter((name) => !current.includes(name))];
|
||
});
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (selectedTargetKey && !targets.some((target) => `${target.target_type}:${target.target_id}` === selectedTargetKey)) {
|
||
setSelectedTargetKey(null);
|
||
}
|
||
}, [selectedTargetKey, targets]);
|
||
|
||
const toggle = (values: string[], value: string, setter: (next: string[]) => void) =>
|
||
setter(values.includes(value) ? values.filter((item) => item !== value) : [...values, value]);
|
||
|
||
const submit = async () => {
|
||
const selectedTargets = targets.filter((target) =>
|
||
selectedTargetKey === `${target.target_type}:${target.target_id}`,
|
||
);
|
||
if (!selectedSkills.length || !selectedTargets.length) {
|
||
toast.error("请至少选择一个技能和一个目标知识库");
|
||
return;
|
||
}
|
||
try {
|
||
const job = await actions.createJob.mutateAsync({
|
||
skill_names: selectedSkills,
|
||
targets: selectedTargets,
|
||
review_mode: "off",
|
||
});
|
||
toast.success(`已创建 ${job.total_count} 个归纳项`);
|
||
setSelectedSkills([]);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : String(error));
|
||
}
|
||
};
|
||
|
||
return (
|
||
<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 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">第一步:选择技能(已选 {selectedSkills.length})</span>
|
||
<Button variant="ghost" size="sm" onClick={toggleFilteredSkills}>
|
||
{allFilteredSelected ? "清空当前结果" : "选择当前结果"}
|
||
</Button>
|
||
</div>
|
||
<div className="relative mb-2">
|
||
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
value={skillQuery}
|
||
onChange={(event) => setSkillQuery(event.target.value)}
|
||
placeholder="按技能中文名、英文名、描述、分类或标签搜索"
|
||
className="pl-8"
|
||
/>
|
||
</div>
|
||
<div className="max-h-60 space-y-1 overflow-y-auto rounded-md border p-2">
|
||
{isLoading ? <Loader2 className="m-4 size-5 animate-spin" /> : filteredSkills.map((skill) => (
|
||
<label key={skill.name} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-muted">
|
||
<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 && (
|
||
<p className="p-3 text-xs text-muted-foreground">没有匹配的技能,请换个关键词。</p>
|
||
)}
|
||
</div>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
当前显示 {filteredSkills.length}/{liveSkills.length} 个技能。
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<span className="text-sm font-medium">第二步:选择一个目标知识库</span>
|
||
</div>
|
||
<div className="space-y-1 rounded-md border p-2">
|
||
{targets.map((target) => {
|
||
const key = `${target.target_type}:${target.target_id}`;
|
||
return (
|
||
<label key={key} className="flex cursor-pointer items-center gap-2 rounded px-2 py-2 hover:bg-muted">
|
||
<Checkbox
|
||
checked={selectedTargetKey === key}
|
||
onCheckedChange={() => setSelectedTargetKey((current) => (current === key ? null : key))}
|
||
/>
|
||
<Database className="size-4 text-muted-foreground" />
|
||
<span className="text-sm">{target.target_name}</span>
|
||
<Badge variant="outline" className="ml-auto text-[10px]">WeKnora Wiki</Badge>
|
||
</label>
|
||
);
|
||
})}
|
||
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
|
||
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
|
||
<p className="p-3 text-xs text-destructive">
|
||
读取 WeKnora 普通知识库失败:
|
||
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
|
||
</p>
|
||
)}
|
||
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
||
<p className="p-3 text-xs text-muted-foreground">
|
||
{runtime.data?.provider !== "weknora"
|
||
? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
||
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<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 || !selectedSkills.length || !selectedTargetKey} onClick={() => void submit()}>
|
||
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
|
||
开始归纳 {selectedSkills.length ? `${selectedSkills.length} 个技能` : ""}
|
||
</Button>
|
||
</div>
|
||
</ScrollArea>
|
||
</section>
|
||
|
||
<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>
|
||
<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 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"}>{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] || 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>
|
||
))}
|
||
</div>
|
||
);
|
||
})}
|
||
{!jobs.data?.length && <p className="py-10 text-center text-sm text-muted-foreground">暂无归纳任务</p>}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
<div className="max-h-80 overflow-auto rounded-lg border bg-card p-3 text-xs">
|
||
<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>
|
||
);
|
||
}
|