(null);
@@ -133,7 +140,7 @@ const ChatBox: React.FC<{
const { skills } = useSkills();
const canScanArtifactDirectory =
- artifactDirectoryModeEnabled &&
+ (artifactDirectoryModeEnabled || !!requestedArtifact) &&
!disableArtifacts &&
!isMock &&
!!threadId &&
@@ -151,17 +158,21 @@ const ChatBox: React.FC<{
);
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
const availableArtifacts = useMemo(
- () =>
- artifactDirectoryModeEnabled
- ? mergeArtifactPaths(
- checkpointArtifacts,
- (artifactListingQuery.data?.files ?? []).map((file) => file.path),
- )
- : checkpointArtifacts,
+ () => {
+ if (!artifactDirectoryModeEnabled && !requestedArtifact) {
+ return checkpointArtifacts;
+ }
+ const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map(
+ (file) => file.path,
+ );
+ if (requestedArtifact) discoveredArtifacts.push(requestedArtifact);
+ return mergeArtifactPaths(checkpointArtifacts, discoveredArtifacts);
+ },
[
artifactDirectoryModeEnabled,
artifactListingQuery.data?.files,
checkpointArtifacts,
+ requestedArtifact,
],
);
@@ -205,6 +216,29 @@ const ChatBox: React.FC<{
thread.isLoading,
);
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
+ useEffect(() => {
+ if (
+ disableArtifacts ||
+ !requestedArtifact ||
+ !threadId ||
+ threadId === "new"
+ ) {
+ return;
+ }
+ const requestKey = `${threadId}:${artifactPathname(requestedArtifact)}`;
+ if (openedRequestedArtifactRef.current === requestKey) return;
+ openedRequestedArtifactRef.current = requestKey;
+ selectArtifact(requestedArtifact);
+ setArtifactsOpen(true);
+ setSideTab("document");
+ }, [
+ disableArtifacts,
+ requestedArtifact,
+ selectArtifact,
+ setArtifactsOpen,
+ threadId,
+ ]);
+
useEffect(() => {
if (!shouldFetchBackendReferenceBatches) {
setBackendReferenceBatches([]);
diff --git a/frontend-web/src/components/workspace/knowledge-product-nav.tsx b/frontend-web/src/components/workspace/knowledge-product-nav.tsx
new file mode 100644
index 00000000..96af3aca
--- /dev/null
+++ b/frontend-web/src/components/workspace/knowledge-product-nav.tsx
@@ -0,0 +1,57 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { BookOpen, Database } from "lucide-react";
+import { useLocation, useNavigate } from "react-router-dom";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { getStoredAuth } from "@/core/auth";
+import {
+ loadKnowledgeBaseProductVersion,
+ type KnowledgeBaseProductVersion,
+ updateKnowledgeBaseProductVersion,
+} 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 });
+}
+
+export function KnowledgeProductNav() {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const client = useQueryClient();
+ const version = useKnowledgeBaseProductVersion();
+ const isAdmin = getStoredAuth()?.system_role === "admin";
+ const save = useMutation({
+ mutationFn: updateKnowledgeBaseProductVersion,
+ onSuccess: (value) => {
+ client.setQueryData(KNOWLEDGE_PRODUCT_KEY, value);
+ toast.success(`默认知识库体验已切换为 ${value}`);
+ },
+ });
+ const assistant = location.pathname.includes("/knowledge/assistant");
+
+ return (
+
+
+
+ {isAdmin && (
+
+ 默认产品版本
+
+
+ )}
+
+ );
+}
diff --git a/frontend-web/src/components/workspace/llmwiki-knowledge-selector.tsx b/frontend-web/src/components/workspace/llmwiki-knowledge-selector.tsx
index afdbd6f3..08274998 100644
--- a/frontend-web/src/components/workspace/llmwiki-knowledge-selector.tsx
+++ b/frontend-web/src/components/workspace/llmwiki-knowledge-selector.tsx
@@ -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 (
+
+
+
+ );
+}
+
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({
{error instanceof Error ? error.message : "知识空间读取失败"}
- ) : groups.length === 0 ? (
+ ) : !hasRows ? (
{search
? "没有匹配的知识空间"
@@ -136,91 +177,181 @@ function SelectionContent({
: "当前账号暂无可用知识空间"}
) : (
- groups.map((group, groupIndex) => (
-
- {groupIndex > 0 && (
- variant === "popover"
- ?
- :
- )}
- {variant === "popover" ? (
-
- {group.key === "personal" ? : }{group.label}
-
- ) : (
-
- {group.key === "personal" ? : }{group.label}
-
- )}
- {group.rows.map((item) => {
- const rowContent = (
- <>
-
-
-
{item.name}
-
- {item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识
-
-
- {item.publication_status === "published" && (
-
公共
- )}
- >
- );
- return variant === "popover" ? (
-
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}
-
+ <>
+ {groups.map((group, groupIndex) => (
+
+ {groupIndex > 0 && (
+ variant === "popover"
+ ?
+ :
+ )}
+ {variant === "popover" ? (
+
+ {group.key === "personal" ? : }{group.label}
+
) : (
-
- );
- })}
-
- ))
+
+ {group.key === "personal" ? : }{group.label}
+
+ )}
+ {group.rows.map((item) => {
+ const rowContent = (
+ <>
+
+
+
{item.name}
+
+ {item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识
+
+
+ {item.publication_status === "published" && (
+
公共
+ )}
+ >
+ );
+ return variant === "popover" ? (
+
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}
+
+ ) : (
+
+ );
+ })}
+
+ ))}
+ {filteredAssistant.length > 0 && (
+
+ {(groups.length > 0) && (
+ variant === "popover"
+ ?
+ :
+ )}
+ {variant === "popover" ? (
+
+ 助手知识库
+
+ ) : (
+
+ 助手知识库
+
+ )}
+ {filteredAssistant.map((item) => {
+ const rowContent = (
+ <>
+
+
+
{item.name}
+
+ Wiki · {item.page_count ?? 0} 页 · {item.entity_count ?? 0} 实体
+
+
+
+ {item.is_global ? "总库" : "助手"}
+
+ >
+ );
+ return variant === "popover" ? (
+
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}
+
+ ) : (
+
+ );
+ })}
+
+ )}
+ >
)}
- {readOnly ? `本对话已选 ${value.length} 个` : `已选择 ${value.length} 个`}
+ {readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
{readOnly ? (
对话开始后不可更改
) : (
-
@@ -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
(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 = (
- {value.length} 个
+ {value.length + assistantValue.length} 个
{content}
@@ -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 ? : }
{selectedLabel}
- {value.length > 0 && (
+ {selectedCount > 0 && (
- {value.length}
+ {selectedCount}
)}
- {value.length > 0 && value.length === rows.length && }
+ {selectedCount > 0 && selectedCount === rows.length + assistantRows.length && }
diff --git a/frontend-web/src/components/workspace/settings/settings-dialog.tsx b/frontend-web/src/components/workspace/settings/settings-dialog.tsx
index 00f587dd..e104cc64 100644
--- a/frontend-web/src/components/workspace/settings/settings-dialog.tsx
+++ b/frontend-web/src/components/workspace/settings/settings-dialog.tsx
@@ -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 & {
defaultSection?: SettingsSection;
@@ -31,7 +34,8 @@ type SettingsDialogProps = React.ComponentProps & {
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(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) {
props.onOpenChange?.(false)} />
)}
{activeSection === "memory" && }
- {activeSection === "tools" && }
+ {activeSection === "tools" && }
+ {activeSection === "encoder" && isAdmin && }
{activeSection === "about" && }
diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx
index d63df2c6..89f53cd0 100644
--- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx
+++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx
@@ -93,6 +93,7 @@ import {
useViewMode,
} from "@/core/system-settings/hooks";
import type { Skill } from "@/core/skills/type";
+import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge";
import { formatDateTime } from "@/core/utils/datetime";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
@@ -101,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 ;
+ return (
void;
+ /** Admin list can inject its single batched binding query here (never N+1). */
+ knowledgeBindings?: SkillKnowledgeBinding[];
}) {
const isCard = layout === "card";
const { t } = useI18n();
@@ -858,6 +869,20 @@ export function SkillRow({
常驻
)}
+ {knowledgeBindings.slice(0, 3).map((binding) => (
+
+ 知识库:{binding.target_name || binding.target_id}
+ {binding.status === "stale" ? "(待更新)" : ""}
+
+ ))}
+ {knowledgeBindings.length > 3 && (
+ +{knowledgeBindings.length - 3}
+ )}
);
diff --git a/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx b/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx
new file mode 100644
index 00000000..732a0912
--- /dev/null
+++ b/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx
@@ -0,0 +1,280 @@
+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 = {
+ 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 = {
+ 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([]);
+ const [skillQuery, setSkillQuery] = useState("");
+ const [selectedTargetKey, setSelectedTargetKey] = useState(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(() => {
+ 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 (
+
+
+
+
归纳技能知识
+
选择技能和一个目标知识库后,系统会在后台提取 Markdown 等知识资料,忽略 Python 等代码文件,自动生成 Wiki、关系和向量索引。
+
+
+
+
+
+ 第一步:选择技能(已选 {selectedSkills.length})
+
+ {allFilteredSelected ? "清空当前结果" : "选择当前结果"}
+
+
+
+
+ setSkillQuery(event.target.value)}
+ placeholder="按技能中文名、英文名、描述、分类或标签搜索"
+ className="pl-8"
+ />
+
+
+ {isLoading ?
: filteredSkills.map((skill) => (
+
+ ))}
+ {!isLoading && !filteredSkills.length && (
+
没有匹配的技能,请换个关键词。
+ )}
+
+
+ 当前显示 {filteredSkills.length}/{liveSkills.length} 个技能。
+
+
+
+
+ 第二步:选择一个目标知识库
+
+
+ {targets.map((target) => {
+ const key = `${target.target_type}:${target.target_id}`;
+ return (
+
+ );
+ })}
+ {(runtime.isLoading || knowledgeBases.isLoading) &&
}
+ {!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
+
+ 读取 WeKnora 普通知识库失败:
+ {knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
+
+ )}
+ {!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
+
+ {runtime.data?.provider !== "weknora"
+ ? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
+ : "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
+
+ )}
+
+
+
+ 第三步:提交后可离开本页。归纳、Wiki 生成、向量化及同步到“知识梳理”总库均在后台完成。
+
+
void submit()}>
+ {actions.createJob.isPending ? : }
+ 开始归纳 {selectedSkills.length ? `${selectedSkills.length} 个技能` : ""}
+
+
+
+
+
+
+
+
+
+
void jobs.refetch()}>
+
+
+
+ {(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 (
+
+
{STATUS_LABELS[job.status] || job.status}{done}/{job.total_count} 项{new Date(job.created_at).toLocaleString()}
+
+ {job.items?.map((item) => (
+
+ {item.skill_name}
+ {PHASE_LABELS[item.phase] || STATUS_LABELS[item.status] || item.phase}
+ {item.progress}%
+ {item.status === "failed" && actions.retry.mutate(item.id)}>重试}
+
+ ))}
+
+ );
+ })}
+ {!jobs.data?.length &&
暂无归纳任务
}
+
+
+
+
+
已归纳关联:{bindings.data?.length ?? 0}
+
{bindings.data?.map((binding) =>
{binding.skill_name}已归纳到 {binding.target_name}{STATUS_LABELS[binding.status] || binding.status} actions.resync.mutate(binding.id)}>重新归纳 { if (window.confirm("解除关联并保留目标知识内容?")) actions.detach.mutate({ id: binding.id, deleteContributions: false }); }}>解除关联{adminMode && { if (window.confirm("解除关联并删除此技能贡献?共享知识仍会保留其他来源。")) actions.detach.mutate({ id: binding.id, deleteContributions: true }); }}>删除贡献}
)}
+
+
+
+ );
+}
diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx
index 107023e2..8e73f74e 100644
--- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx
+++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx
@@ -14,7 +14,7 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
-import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
+import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, 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: "智能体",
diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx
index 8e4ac50d..57c71201 100644
--- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx
+++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx
@@ -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() {
{t.common.settings}