feat: improve assistant knowledge import and database compatibility

This commit is contained in:
吉鑫升 committed 2026-09-02 17:55:17 +08:00
1 parent fbd8dec48f
commit 6b724bd00d
51 files changed
+4393 -1021

No files matched your search

@@ -13,6 +13,8 @@ import {
} from "@/core/system-settings/api";
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
export function useKnowledgeBaseProductVersion() {
return useQuery({ queryKey: KNOWLEDGE_PRODUCT_KEY, queryFn: loadKnowledgeBaseProductVersion, staleTime: 60_000 });
@@ -35,7 +37,7 @@ export function KnowledgeProductNav() {
return (
<div className="flex items-center gap-2 border-b bg-card px-6 py-2">
<Button size="sm" variant={assistant ? "ghost" : "secondary"} onClick={() => navigate("/page/workspace/knowledge")}>
<Button size="sm" variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
<BookOpen />普通知识库
</Button>
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
@@ -22,6 +22,10 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
type AssistantKnowledgeBase,
useAssistantKnowledgeBases,
} from "@/strategy-components/api/assistant-knowledge";
import {
type LlmWikiKnowledgeBase,
useAgentLlmWikiKnowledgeBases,
@@ -66,10 +70,26 @@ function SpaceIcon({ item }: { item: LlmWikiKnowledgeBase }) {
);
}
function AssistantSpaceIcon({ item }: { item: AssistantKnowledgeBase }) {
return (
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md",
item.is_global ? "bg-amber-500/10 text-amber-600 dark:text-amber-400" : "bg-sky-500/10 text-sky-600 dark:text-sky-400",
)}
>
<Database className="size-3.5" />
</span>
);
}
function SelectionContent({
rows,
assistantRows,
value,
assistantValue,
onChange,
onAssistantChange,
search,
onSearchChange,
loading,
@@ -79,8 +99,11 @@ function SelectionContent({
readOnly,
}: {
rows: LlmWikiKnowledgeBase[];
assistantRows: AssistantKnowledgeBase[];
value: string[];
assistantValue: string[];
onChange: (value: string[]) => void;
onAssistantChange: (value: string[]) => void;
search: string;
onSearchChange: (value: string) => void;
loading: boolean;
@@ -90,6 +113,7 @@ function SelectionContent({
readOnly: boolean;
}) {
const selected = useMemo(() => new Set(value), [value]);
const selectedAssistant = useMemo(() => new Set(assistantValue), [assistantValue]);
const filtered = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
if (!query) return rows;
@@ -97,11 +121,28 @@ function SelectionContent({
`${item.name} ${item.description}`.toLocaleLowerCase().includes(query),
);
}, [rows, search]);
const filteredAssistant = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
if (!query) return assistantRows;
return assistantRows.filter((item) =>
`${item.name} ${item.description ?? ""}`.toLocaleLowerCase().includes(query),
);
}, [assistantRows, search]);
const groups = useMemo(() => groupRows(filtered), [filtered]);
const toggle = (id: string) => {
if (readOnly) return;
onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]);
};
const toggleAssistant = (id: string) => {
if (readOnly) return;
onAssistantChange(
selectedAssistant.has(id)
? assistantValue.filter((item) => item !== id)
: [...assistantValue, id],
);
};
const selectedCount = value.length + assistantValue.length;
const hasRows = groups.length > 0 || filteredAssistant.length > 0;
return (
<>
@@ -127,7 +168,7 @@ function SelectionContent({
<div className="m-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
{error instanceof Error ? error.message : "知识空间读取失败"}
</div>
) : groups.length === 0 ? (
) : !hasRows ? (
<div className="flex h-28 items-center justify-center px-4 text-center text-xs text-muted-foreground">
{search
? "没有匹配的知识空间"
@@ -136,91 +177,181 @@ function SelectionContent({
: "当前账号暂无可用知识空间"}
</div>
) : (
groups.map((group, groupIndex) => (
<div key={group.key}>
{groupIndex > 0 && (
variant === "popover"
? <DropdownMenuSeparator className="my-1.5" />
: <div className="my-1.5 border-t" />
)}
{variant === "popover" ? (
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
</DropdownMenuLabel>
) : (
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
</div>
)}
{group.rows.map((item) => {
const rowContent = (
<>
<SpaceIcon item={item} />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{item.name}</div>
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
{item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识
</div>
</div>
{item.publication_status === "published" && (
<Badge variant="outline" className="h-4 px-1 text-[9px]">公共</Badge>
)}
</>
);
return variant === "popover" ? (
<DropdownMenuCheckboxItem
key={item.id}
checked={selected.has(item.id)}
disabled={readOnly}
onCheckedChange={() => toggle(item.id)}
onSelect={(event) => event.preventDefault()}
className={cn(
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
selected.has(item.id) && "bg-primary/8 focus:bg-primary/12",
)}
>
{rowContent}
</DropdownMenuCheckboxItem>
<>
{groups.map((group, groupIndex) => (
<div key={group.key}>
{groupIndex > 0 && (
variant === "popover"
? <DropdownMenuSeparator className="my-1.5" />
: <div className="my-1.5 border-t" />
)}
{variant === "popover" ? (
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
</DropdownMenuLabel>
) : (
<button
key={item.id}
type="button"
role="checkbox"
aria-checked={selected.has(item.id)}
disabled={readOnly}
onClick={() => toggle(item.id)}
className={cn(
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
selected.has(item.id) && "bg-primary/8",
)}
>
<span className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border",
selected.has(item.id) && "border-primary bg-primary text-primary-foreground",
)}>
{selected.has(item.id) && <Check className="size-3" />}
</span>
{rowContent}
</button>
);
})}
</div>
))
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
</div>
)}
{group.rows.map((item) => {
const rowContent = (
<>
<SpaceIcon item={item} />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{item.name}</div>
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
{item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识
</div>
</div>
{item.publication_status === "published" && (
<Badge variant="outline" className="h-4 px-1 text-[9px]">公共</Badge>
)}
</>
);
return variant === "popover" ? (
<DropdownMenuCheckboxItem
key={item.id}
checked={selected.has(item.id)}
disabled={readOnly}
onCheckedChange={() => toggle(item.id)}
onSelect={(event) => event.preventDefault()}
className={cn(
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
selected.has(item.id) && "bg-primary/8 focus:bg-primary/12",
)}
>
{rowContent}
</DropdownMenuCheckboxItem>
) : (
<button
key={item.id}
type="button"
role="checkbox"
aria-checked={selected.has(item.id)}
disabled={readOnly}
onClick={() => toggle(item.id)}
className={cn(
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
selected.has(item.id) && "bg-primary/8",
)}
>
<span className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border",
selected.has(item.id) && "border-primary bg-primary text-primary-foreground",
)}>
{selected.has(item.id) && <Check className="size-3" />}
</span>
{rowContent}
</button>
);
})}
</div>
))}
{filteredAssistant.length > 0 && (
<div>
{(groups.length > 0) && (
variant === "popover"
? <DropdownMenuSeparator className="my-1.5" />
: <div className="my-1.5 border-t" />
)}
{variant === "popover" ? (
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
<Database className="size-3" />助手知识库
</DropdownMenuLabel>
) : (
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
<Database className="size-3" />助手知识库
</div>
)}
{filteredAssistant.map((item) => {
const rowContent = (
<>
<AssistantSpaceIcon item={item} />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{item.name}</div>
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
Wiki · {item.page_count ?? 0} 页 · {item.entity_count ?? 0} 实体
</div>
</div>
<Badge variant="outline" className="h-4 px-1 text-[9px]">
{item.is_global ? "总库" : "助手"}
</Badge>
</>
);
return variant === "popover" ? (
<DropdownMenuCheckboxItem
key={`assistant:${item.id}`}
checked={selectedAssistant.has(item.id)}
disabled={readOnly}
onCheckedChange={() => toggleAssistant(item.id)}
onSelect={(event) => event.preventDefault()}
className={cn(
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
selectedAssistant.has(item.id) && "bg-primary/8 focus:bg-primary/12",
)}
>
{rowContent}
</DropdownMenuCheckboxItem>
) : (
<button
key={`assistant:${item.id}`}
type="button"
role="checkbox"
aria-checked={selectedAssistant.has(item.id)}
disabled={readOnly}
onClick={() => toggleAssistant(item.id)}
className={cn(
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
selectedAssistant.has(item.id) && "bg-primary/8",
)}
>
<span className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border",
selectedAssistant.has(item.id) && "border-primary bg-primary text-primary-foreground",
)}>
{selectedAssistant.has(item.id) && <Check className="size-3" />}
</span>
{rowContent}
</button>
);
})}
</div>
)}
</>
)}
</div>
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
<span className="text-[11px] text-muted-foreground">
{readOnly ? `本对话已选 ${value.length} 个` : `已选择 ${value.length} 个`}
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
</span>
{readOnly ? (
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
) : (
<div className="flex items-center gap-1">
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => {
onChange(filtered.map((item) => item.id));
onAssistantChange(filteredAssistant.map((item) => item.id));
}}
>
全选
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => {
onChange([]);
onAssistantChange([]);
}}
>
清空
</Button>
</div>
@@ -232,7 +363,9 @@ function SelectionContent({
export function LlmWikiKnowledgeSelector({
value,
assistantValue = [],
onChange,
onAssistantChange = () => undefined,
source = "all",
agentId,
autoSelectAll = false,
@@ -243,7 +376,9 @@ export function LlmWikiKnowledgeSelector({
variant = "popover",
}: {
value: string[];
assistantValue?: string[];
onChange: (value: string[]) => void;
onAssistantChange?: (value: string[]) => void;
source?: SelectorSource;
agentId?: string | null;
autoSelectAll?: boolean;
@@ -257,28 +392,39 @@ export function LlmWikiKnowledgeSelector({
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const runtime = useLlmWikiRuntime();
const weknoraEnabled = runtime.data?.provider === "weknora";
const all = useLlmWikiKnowledgeBases(
"all",
runtime.data?.provider === "weknora",
weknoraEnabled,
);
const agent = useAgentLlmWikiKnowledgeBases(
agentId,
runtime.data?.provider === "weknora" && source === "agent" && !!agentId,
weknoraEnabled && source === "agent" && !!agentId,
);
const rows = all.data ?? [];
const assistantBases = useAssistantKnowledgeBases();
const rows = weknoraEnabled ? (all.data ?? []) : [];
const assistantRows = assistantBases.data ?? [];
const defaultIds = useMemo(() => {
if (source !== "agent") {
return rows.map((item) => item.id);
}
return (agent.data ?? []).map((item) => item.id);
}, [agent.data, rows, source]);
const loading = all.isLoading || (source === "agent" && !!agentId && agent.isLoading);
const error = all.error ?? (source === "agent" ? agent.error : null);
const loading = runtime.isLoading || (weknoraEnabled && all.isLoading) || assistantBases.isLoading || (source === "agent" && !!agentId && agent.isLoading);
const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? assistantBases.error;
const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]);
const assistantAvailableIds = useMemo(
() => new Set(assistantRows.map((item) => item.id)),
[assistantRows],
);
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
const initialized = useRef<string | null>(null);
useEffect(() => {
if (!runtime.isLoading && !weknoraEnabled) {
if (value.length > 0) onChange([]);
return;
}
if (!all.data || (source === "agent" && agentId && !agent.data) || initialized.current === initializationKey) return;
initialized.current = initializationKey;
const valid = value.filter((id) => availableIds.has(id));
@@ -288,19 +434,30 @@ export function LlmWikiKnowledgeSelector({
if (next.length !== value.length || next.some((id, index) => id !== value[index])) {
onChange(next);
}
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, source, value]);
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, runtime.isLoading, source, value, weknoraEnabled]);
useEffect(() => {
if (!assistantBases.data) return;
const valid = assistantValue.filter((id) => assistantAvailableIds.has(id));
if (valid.length !== assistantValue.length || valid.some((id, index) => id !== assistantValue[index])) {
onAssistantChange(valid);
}
}, [assistantAvailableIds, assistantBases.data, assistantValue, onAssistantChange]);
useEffect(() => {
if (!open) setSearch("");
}, [open]);
if (runtime.isError || runtime.data?.provider !== "weknora") return null;
if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null;
const content = (
<SelectionContent
rows={rows}
assistantRows={assistantRows}
value={value}
assistantValue={assistantValue}
onChange={onChange}
onAssistantChange={onAssistantChange}
search={search}
onSearchChange={setSearch}
loading={loading}
@@ -323,7 +480,7 @@ export function LlmWikiKnowledgeSelector({
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
</p>
</div>
<Badge variant="secondary" className="shrink-0">{value.length} 个</Badge>
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
</div>
{content}
</div>
@@ -333,10 +490,15 @@ export function LlmWikiKnowledgeSelector({
const selectedNames = value
.map((id) => rows.find((item) => item.id === id)?.name)
.filter((name): name is string => !!name);
const selectedLabel = selectedNames.length > 0
? selectedNames.join("、")
: value.length > 0
? `已选 ${value.length} 个知识空间`
const selectedAssistantNames = assistantValue
.map((id) => assistantRows.find((item) => item.id === id)?.name)
.filter((name): name is string => !!name);
const allSelectedNames = [...selectedNames, ...selectedAssistantNames];
const selectedCount = value.length + assistantValue.length;
const selectedLabel = allSelectedNames.length > 0
? allSelectedNames.join("、")
: selectedCount > 0
? `已选 ${selectedCount} 个知识空间`
: label;
return (
@@ -348,20 +510,20 @@ export function LlmWikiKnowledgeSelector({
size="sm"
className={cn(
"h-8 max-w-52 gap-1.5 rounded-full border-0 bg-transparent px-2.5 text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground",
value.length > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary",
selectedCount > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary",
className,
)}
disabled={disabled || loading}
title={value.length > 0 ? selectedLabel : undefined}
title={selectedCount > 0 ? selectedLabel : undefined}
>
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <Database className="size-3.5" />}
<span className="truncate text-xs font-normal">{selectedLabel}</span>
{value.length > 0 && (
{selectedCount > 0 && (
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
{value.length}
{selectedCount}
</span>
)}
{value.length > 0 && value.length === rows.length && <Check className="size-3" />}
{selectedCount > 0 && selectedCount === rows.length + assistantRows.length && <Check className="size-3" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={8} className="w-80 overflow-hidden rounded-xl p-0 shadow-xl">
@@ -1,4 +1,4 @@
import { CheckCircle2, Database, Loader2, RefreshCw, Sparkles } from "lucide-react";
import { Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -10,18 +10,12 @@ 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 {
useAssistantKnowledgeActions,
useAssistantKnowledgeBase,
} from "@/strategy-components/api/assistant-knowledge";
import { useLlmWikiKnowledgeBases, useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import {
type SkillKnowledgeTarget,
useSkillKnowledgeActions,
useSkillKnowledgeAutoSyncSettings,
useSkillKnowledgeBindings,
useSkillKnowledgeJobs,
useSkillKnowledgeReviews,
} from "@/strategy-components/api/skill-knowledge";
const PHASE_LABELS: Record<string, string> = {
@@ -39,53 +33,82 @@ export function SkillKnowledgePanel() {
const { skills, isLoading } = useAllSkillsAdmin(true);
const liveSkills = useMemo(() => skills.filter((skill) => (skill.state ?? "active") === "active"), [skills]);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [selectedTargetKeys, setSelectedTargetKeys] = useState<string[]>([]);
const [reviewMode, setReviewMode] = useState<"required" | "auto_high_confidence" | "off">("auto_high_confidence");
const [skillQuery, setSkillQuery] = useState("");
const [selectedTargetKey, setSelectedTargetKey] = useState<string | null>(null);
const [reviewMode, setReviewMode] = useState<"auto_high_confidence" | "off">("auto_high_confidence");
const runtime = useLlmWikiRuntime();
const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora");
const assistantBase = useAssistantKnowledgeBase();
const assistantActions = useAssistantKnowledgeActions();
const bindings = useSkillKnowledgeBindings();
const jobs = useSkillKnowledgeJobs();
const reviews = useSkillKnowledgeReviews();
const actions = useSkillKnowledgeActions();
const autoSettings = useSkillKnowledgeAutoSyncSettings();
const [scheduleCron, setScheduleCron] = useState("0 */6 * * *");
const [debounceSeconds, setDebounceSeconds] = useState(120);
useEffect(() => {
if (!autoSettings.data) return;
setScheduleCron(autoSettings.data.schedule_cron);
setDebounceSeconds(autoSettings.data.debounce_seconds);
}, [autoSettings.data]);
const targets = useMemo<SkillKnowledgeTarget[]>(() => {
const rows: SkillKnowledgeTarget[] = (knowledgeBases.data ?? []).map((kb) => ({
target_type: "weknora",
target_mode: kb.can_write ? kb.type === "faq" ? "faq" : kb.type === "wiki" ? "wiki" : "document" : "readonly_sidecar",
target_id: kb.id,
target_name: kb.name,
remote_business_key: `weknora:kb:${kb.id}`,
readonly: !kb.can_write,
}));
if (assistantBase.data) {
rows.unshift({
target_type: "assistant",
target_mode: "assistant",
target_id: assistantBase.data.id,
target_name: assistantBase.data.name,
remote_business_key: `assistant:base:${assistantBase.data.id}`,
});
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);
}
return rows;
}, [assistantBase.data, knowledgeBases.data]);
}, [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) =>
selectedTargetKeys.includes(`${target.target_type}:${target.target_id}`),
selectedTargetKey === `${target.target_type}:${target.target_id}`,
);
if (!selectedSkills.length || !selectedTargets.length) {
toast.error("请至少选择一个技能和一个目标知识库");
@@ -108,46 +131,76 @@ export function SkillKnowledgePanel() {
<div className="grid size-full min-h-0 grid-cols-[minmax(320px,0.9fr)_minmax(420px,1.1fr)] gap-4 overflow-hidden 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">按“技能 × 目标知识库”生成独立后台任务;代码与脚本不会被读取。</p>
<h2 className="font-semibold">技能到知识库管理</h2>
<p className="mt-1 text-xs text-muted-foreground">一次选择多个技能归纳到一个 WeKnora 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>
<Button variant="ghost" size="sm" onClick={() => setSelectedSkills(selectedSkills.length === liveSkills.length ? [] : liveSkills.map((skill) => skill.name))}>全选/清空</Button>
<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" /> : liveSkills.map((skill) => (
{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>
</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">2. 选择目标({selectedTargetKeys.length})</span>
{!assistantBase.data && (
<Button size="sm" variant="outline" disabled={assistantActions.initialize.isPending} onClick={() => assistantActions.initialize.mutate("知识梳理")}>创建知识梳理库</Button>
)}
<span className="text-sm font-medium">2. 选择目标知识库({selectedTargetKey ? 1 : 0})</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={selectedTargetKeys.includes(key)} onCheckedChange={() => toggle(selectedTargetKeys, key, setSelectedTargetKeys)} />
<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]">{target.target_type === "assistant" ? "助手库" : target.target_mode === "readonly_sidecar" ? "只读·本地投影" : target.target_mode}</Badge>
<Badge variant="outline" className="ml-auto text-[10px]">WeKnora Wiki</Badge>
</label>
);
})}
{!targets.length && <p className="p-3 text-xs text-muted-foreground">未配置普通知识库;可先创建全局“知识梳理”助手库。</p>}
{(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="flex items-center gap-3">
@@ -156,14 +209,13 @@ export function SkillKnowledgePanel() {
<SelectTrigger className="w-52"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="auto_high_confidence">高置信自动通过</SelectItem>
<SelectItem value="required">全部逐条审核</SelectItem>
<SelectItem value="off">不阻塞写入</SelectItem>
</SelectContent>
</Select>
</div>
<Button className="w-full" disabled={actions.createJob.isPending} onClick={() => void submit()}>
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
开始归纳({selectedSkills.length * selectedTargetKeys.length} 项)
开始归纳({selectedSkills.length * (selectedTargetKey ? 1 : 0)} 项)
</Button>
</div>
</ScrollArea>
@@ -199,26 +251,9 @@ export function SkillKnowledgePanel() {
</div>
</ScrollArea>
</div>
{(reviews.data?.length ?? 0) > 0 && (
<div className="max-h-64 overflow-auto rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center"><h2 className="font-semibold">待审核({reviews.data!.length})</h2><Button size="sm" className="ml-auto" onClick={() => actions.review.mutate({ review_ids: reviews.data!.map((row) => row.id), action: "approve" })}><CheckCircle2 />批量批准</Button></div>
{reviews.data!.map((review) => <div key={review.id} className="mb-2 flex items-center gap-2 rounded border p-2 text-xs"><Badge variant="outline">{review.object_type}</Badge><code className="truncate">{String(review.original.name || review.original.predicate || review.object_key)}</code><Button size="sm" variant="ghost" className="ml-auto" onClick={() => actions.review.mutate({ review_ids: [review.id], action: "approve" })}>批准</Button><Button size="sm" variant="ghost" onClick={() => actions.review.mutate({ review_ids: [review.id], action: "reject" })}>拒绝</Button></div>)}
</div>
)}
<div className="max-h-80 overflow-auto rounded-lg border bg-card p-3 text-xs">
<div className="mb-3 flex flex-wrap items-center gap-3 border-b pb-3">
<span className="font-medium">自动同步</span>
{autoSettings.data && <>
<label className="flex items-center gap-1"><Checkbox checked={autoSettings.data.watch_enabled} onCheckedChange={(checked) => actions.updateAutoSyncSettings.mutate({ watch_enabled: checked === true })} />文件监听</label>
<label className="flex items-center gap-1"><Checkbox checked={autoSettings.data.schedule_enabled} onCheckedChange={(checked) => actions.updateAutoSyncSettings.mutate({ schedule_enabled: checked === true })} />定时同步</label>
<label className="flex items-center gap-1"><Checkbox checked={autoSettings.data.write_remote_enabled} onCheckedChange={(checked) => actions.updateAutoSyncSettings.mutate({ write_remote_enabled: checked === true })} />自动写入目标</label>
<Input className="h-8 w-32" value={scheduleCron} onChange={(event) => setScheduleCron(event.target.value)} aria-label="自动同步 cron" />
<Input className="h-8 w-24" type="number" min={10} value={debounceSeconds} onChange={(event) => setDebounceSeconds(Number(event.target.value))} aria-label="防抖秒数" />
<Button size="sm" variant="outline" onClick={() => actions.updateAutoSyncSettings.mutate({ schedule_cron: scheduleCron, debounce_seconds: debounceSeconds }, { onSuccess: () => toast.success("自动同步设置已保存"), onError: (error) => toast.error(error.message) })}>保存</Button>
</>}
</div>
<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><label className="ml-auto flex items-center gap-1"><Checkbox checked={binding.auto_sync_enabled} onCheckedChange={(checked) => actions.setAutoSync.mutate({ id: binding.id, enabled: checked === true })} />自动</label><Button size="sm" variant="outline" 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="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>
</section>
</div>
@@ -14,7 +14,7 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, FileSearch, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, FileSearch, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -65,6 +65,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/chats",
match: (pathname) => pathname === "/page/workspace/chats",
},
{
id: "workspace-library",
label: "资料库",
icon: Library,
path: "/page/workspace/library",
match: (pathname) => pathname === "/page/workspace/library",
},
{
id: "workspace-agents",
label: "智能体",
@@ -229,6 +229,14 @@ export function WorkspaceNavMenu() {
技能管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skills?tab=knowledge")}
>
<DatabaseZapIcon />
技能到知识库管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/models")}