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

@@ -1,8 +1,10 @@
# 技能归纳、WeKnora 普通知识库与助手知识库:实现设计
本文档用于指导大模型或开发者实现知识库体系改造。整体包含两条线:一条是“将一个或多个技能归纳到一个或多个目标知识库”;另一条是新增一个自研的全局助手知识库,用于承接 WeKnora 已处理、已向量化后的 Wiki、实体和关系数据,也支持技能和管理员本地数据直接导入,并做统一实体对齐、Wiki 展示和版本管理。
本文档用于指导大模型或开发者实现知识库体系改造。整体包含两条线:一条是“将一个或多个技能归纳到一个 WeKnora Wiki 普通知识库”;另一条是新增自研助手知识库体系。助手知识库像普通知识库一样可以创建多个库,同时系统内置一个全局大库,用于统计全库数据、承接所有 WeKnora 导入沉淀、做全局实体对齐、Wiki 展示和导入审计。
本文档中,目标知识库既可以是 WeKnora 普通知识库,也可以是自研助手知识库。普通知识库负责向量化、检索、Wiki 和图谱;助手知识库负责全局知识梳理、实体对齐、Wiki 展示、搜索和版本管理。
本文档中,技能归纳目标只能是 WeKnora Wiki 普通知识库;助手知识库不接收技能直接归纳,也不接收任意本地文件。普通知识库负责向量化、检索、Wiki 和图谱;助手知识库负责自研存储、WeKnora 导入、实体对齐、Wiki 展示、搜索和导入审计。无论 WeKnora 数据导入到哪个助手知识库,最终都必须沉淀一份到系统全局大库。
> 最终约束:如果本文历史章节与本节冲突,以本节和“2. 当前边界”中的约束为准。
## 1. 背景与目标
@@ -14,57 +16,59 @@
本次要新增一条管理员工作流:
管理员在“技能管理”页面选择多个技能,再选择一个或多个目标知识库,点击归纳。系统将每个技能作为独立来源读取、解析、抽取和归纳,把结果写入对应知识库,并在技能列表和技能详情中展示“该技能已被哪些知识库归纳”。
管理员在“技能管理”页面选择多个技能,再选择一个目标 WeKnora Wiki 普通知识库,点击归纳。系统将每个技能作为独立来源读取、解析、抽取和归纳,把结果写入对应 WeKnora 知识库,并在技能列表和技能详情中展示“该技能已被哪些知识库归纳”。如果需要归纳到多个普通知识库,通过多次操作完成。
用户已确认的业务约束:
- 一次操作可以选择多个技能,也可以选择多个目标知识库;系统按 `技能 x 目标知识库` 生成处理矩阵。
- 一次操作可以选择多个技能,但只能选择一个目标 WeKnora Wiki 普通知识库。
- 同一个技能可以在不同时间归纳到多个知识库,所以最终关系是技能与知识库多对多。
- V1 支持归纳到多种目标:Wiki WeKnora、非 Wiki WeKnora、FAQ 知识库、系统会话存档知识库、只读知识库,以及自研助手知识库。
- 多知识库归纳通过多次操作完成;每次操作形成一批 `技能 x 单目标知识库` 任务。
- V1 只支持归纳到可写 WeKnora Wiki 普通知识库;不支持非 Wiki、FAQ、系统会话存档、只读知识库作为技能归纳目标。
- 技能归纳时只抽取数据、业务说明、实体、概念、对象、事件、关系等知识;Python、TypeScript、Shell 等代码和脚本类文件完全排除,不读取注释、不抽取函数、不上传、不进入 prompt。
- 技能里的 Markdown、业务数据文件、表格、PDF、Office、图片等要纳入处理范围。
- 需要同步三类内容:原始资料、归纳后的 Wiki、实体与关系。
- 关系来源包括 Markdown 自然语言描述、目录和文件引用、模型自动推断。
- 允许模型推断关系,同时前端需要提供逐条实体/关系预览审核能力。
- 支持手动重新归纳,也支持自动文件监听、定时后台同步和技能变更后的自动远程写入。
- 允许模型推断关系,但前端不提供逐条实体/关系预览审核。
- 支持管理员手动点击“重新归纳”;不新增自动文件监听、定时后台同步或技能变更自动远程写入。
- 解除关联时弹窗让用户选择:保留知识库内容,或删除该技能贡献的内容。
- 权限仅管理员。
- 总技能数约 200 个,需要支持批量进度、后台执行、失败重试。
- 同一目标知识库内,不同技能抽取出的同一实体需要合并。
- 重归纳时先生成新版本,成功后再替换旧版本;失败时旧版本继续可用。
- 新增知识库类型区分:普通知识库是 WeKnora,助手知识库是自研存储。
- 助手知识库只有一个全局大库,建议命名为“知识梳理”,所有用户可见。
- 助手知识库支持创建多个库;其中有且只有一个系统全局大库,建议命名为“知识梳理总库”,所有用户可见。
- 助手知识库只提供 Wiki 页面展示、搜索和数据导入;不落到 WeKnora。
- 助手知识库数据来源包括 WeKnora 导出、技能直接归纳、管理员本地文件导入。
- WeKnora 已向量化和处理完成的 Wiki、文档、实体、关系、图谱数据默认沉淀到助手知识库。
- 普通知识库需要提供导出或“导入到助手知识库”的功能,把 WeKnora 里已经处理好的数据导入助手知识库;导出数据需要包含 Wiki、文档、chunk、实体、关系、图谱。
- 即使没有配置 WeKnora,也要默认展示助手知识库的创建或初始化入口。
- 助手知识库数据来源只能是 WeKnora 导出包或从某个 WeKnora 知识库触发导入。
- WeKnora 已向量化和处理完成的 Wiki、文档、实体、关系、图谱数据默认沉淀到助手知识库;如果导入的是某个自建助手库,也必须同步沉淀到全局大库。
- 普通知识库需要提供导出或“导入到助手知识库”的功能,把 WeKnora 里已经处理好的数据导入指定助手知识库;导出数据需要包含 Wiki、文档、chunk、实体、关系、图谱。
- 即使没有配置 WeKnora 地址,也要默认展示助手知识库列表和创建入口,而不是展示空的普通知识库页面。
- 管理员可切换知识库产品版本:`v1` / `v2`,默认 `v1`。
- 助手知识库普通用户只读,管理员可导入、编辑、删除、查看版本和回滚 Wiki 页面。
## 2. 必须支持的扩展能力
## 2. 当前边界
以下能力原先按非目标处理,最新需求已确认全部要支持。实现时必须把它们作为正式目标,并加上必要的权限、状态和审计护栏。
以下能力明确不做,开发实现和验收必须按这些边界收敛:
- 支持一次选择多个目标知识库。一个归纳任务可包含多个技能和多个目标,后端按矩阵拆分 item。
- 支持非 Wiki 知识库、FAQ 知识库、系统会话存档知识库、只读知识库作为目标。不同目标使用不同写入 adapter;只读目标使用本地 sidecar 投影和待推送状态。
- 支持直接写 WeKnora 的 Neo4j 或底层图数据库,但必须通过受控 graph writer、显式配置开关、事务和审计,不允许 LLM 生成任意 Cypher 直接执行。
- 支持自动文件监听、定时后台同步、技能变更自动远程写入。自动写入仍必须走 staged 版本和失败保留旧版本机制。
- 前端提供逐条实体/关系预览审核,管理员可批准、拒绝、编辑、批量处理。
- 前端和 API 暴露远端 WeKnora ID,并可作为跨系统业务 key 使用;本地仍保留 surrogate id 以保证数据库关系稳定。
- 支持技能直接归纳到助手知识库,不必先经过 WeKnora。
- 支持管理员向助手知识库导入任意本地文件,导入前必须做文件类型、安全扫描和敏感信息阻断。
- 助手知识库 V1 支持 Wiki 页面版本管理、版本对比、发布、回滚和导入审计。
- 不支持一次选择多个目标知识库。多知识库归纳通过多次操作完成。
- 不支持非 Wiki 知识库、FAQ 知识库、系统会话存档知识库、只读知识库作为技能归纳目标。
- 不直接写 WeKnora 的 Neo4j 或底层图数据库。
- 不新增自动文件监听、定时后台同步或技能变更自动远程写入。
- 不在前端提供逐条实体/关系预览审核。
- 不把远端 WeKnora 内部 ID 暴露为前端业务主键。
- 不支持技能直接归纳到助手知识库。技能先进入 WeKnora 普通知识库,再由 WeKnora 导出沉淀到助手知识库。
- 不支持管理员向助手知识库导入任意本地文件;手动导入也必须是 WeKnora 导出包或从某个 WeKnora 知识库触发。
- 助手知识库 V1 不做页面版本管理,只保留当前合并后的 Wiki 状态和导入审计记录。
## 3. 术语
| 术语 | 含义 |
| --- | --- |
| 技能 | DeerFlow 技能目录,通常包含 `SKILL.md`、代码、配置、示例、文档、数据文件等。 |
| 目标知识库 | 管理员选择的一个或多个知识库,可以是 WeKnora 普通知识库,也可以是自研助手知识库。 |
| 目标知识库 | 管理员选择的一个 WeKnora Wiki 普通知识库。助手知识库不是技能归纳目标。 |
| 普通知识库 | WeKnora 知识库,负责向量化、Wiki、文档检索和图谱能力。 |
| 助手知识库 | DeerFlow 自研知识库,只维护一个全局大库,用于 Wiki 展示、搜索、WeKnora 导入、技能直接导入、本地文件导入和实体对齐。 |
| 知识梳理底库 | 助手知识库的默认全局库名,所有用户可见,普通用户只读。 |
| 助手知识库 | DeerFlow 自研知识库类型,支持创建多个库,用于 Wiki 展示、搜索、WeKnora 导入和实体对齐。 |
| 知识梳理总库 | 助手知识库体系中的系统全局大库,有且只有一个,自动统计和沉淀所有助手库与普通知识库导入数据,做全局实体对齐。 |
| 自建助手库 | 管理员创建的普通助手知识库,类似普通知识库列表中的一个库,可导入 WeKnora 普通知识库导出数据。 |
| 产品版本 | 知识库产品形态开关。`v1` 默认为普通知识库体验,`v2` 默认为助手知识库体验。 |
| WeKnora 导出包 | 从普通知识库导出的已处理知识集合,包含 Wiki 页面、文档、chunk、实体、关系、图谱等。 |
| 绑定 | 一个技能与一个目标知识库之间的持久关联。`skill_name + target_type + target_id` 唯一。 |
@@ -81,37 +85,35 @@
```mermaid
flowchart LR
A[管理员选择多个技能] --> B[选择一个或多个目标知识库]
B --> C[创建归纳任务矩阵]
A[管理员选择多个技能] --> B[选择一个 WeKnora Wiki 目标知识库]
B --> C[创建归纳任务]
C --> D[逐技能扫描与解析]
D --> E[生成技能快照]
E --> F[LLM 归纳 Wiki 与关系抽取]
F --> R[前端实体/关系预览审核]
R --> G[按目标知识库合并实体/关系]
G --> H[写入 WeKnora 或本地助手库]
G --> I[写入 Wiki/FAQ/会话存档/sidecar]
G --> J[写入 WeKnora GraphRAG 或 Neo4j]
F --> G[按目标 WeKnora Wiki 合并实体/关系]
G --> H[写入 WeKnora 原始资料与 Wiki]
G --> I[写入关系投影文档]
H --> K[校验远端写入]
I --> K
J --> K
K --> L[本地原子激活新版本]
L --> M[技能页展示已归纳知识库]
K --> N[WeKnora 导出已处理数据]
N --> O[沉淀到助手知识库]
O --> P[全局实体对齐]
P --> Q[知识梳理 Wiki 展示与搜索]
N --> O[导入指定助手知识库]
O --> P[同步沉淀到知识梳理总库]
P --> Q[全局实体对齐]
Q --> S[总库 Wiki 展示与搜索]
```
关键原则:
- DeerFlow 本地数据库保存权威绑定关系、版本状态、来源贡献和远端 artifact 映射。
- WeKnora 保存可检索知识内容,包括原文、Wiki 页面、FAQ、会话存档、关系投影文档和图谱。
- 对 WeKnora 图谱能力采取能力探测和分层写入:优先使用 WeKnora API;需要底层图写入时,使用受控 Neo4j graph writer。
- 不直接写 WeKnora 底层图数据库;关系以 WeKnora API 能力和关系投影文档承接。
- 重归纳使用 staged artifact,远端写入和校验成功后,本地才激活新版本。
- 删除或解除关联只影响该技能在目标知识库中的贡献,不误删其他技能共享的实体和关系。
- 助手知识库只有一个全局库,可以接收技能直接归纳、WeKnora 导出和管理员本地文件导入。
- WeKnora 的已处理结果默认进入助手知识库,由助手底库做全局实体对齐、Wiki 展示、搜索和版本管理。
- 未配置 WeKnora 时,仍展示助手知识库创建或初始化能力;与 WeKnora 相关的归纳和导入动作置灰或隐藏。
- 助手知识库可以创建多个库;系统全局大库只有一个,负责汇总全库数据和做跨库实体对齐。
- WeKnora 的已处理结果可以导入指定助手知识库;无论是否指定自建助手库,都必须同步沉淀到知识梳理总库。
- 未配置 WeKnora 地址时,默认进入助手知识库页面,展示助手知识库列表、创建入口和知识梳理总库;与 WeKnora 相关的导出导入动作置灰或隐藏。
## 5. 现有代码落点
@@ -193,6 +195,14 @@ items = selected_skills x selected_targets
每个 item 对应一个 `binding`,一个技能归纳到三个目标知识库,就生成三个 binding。父 job 负责展示整体进度,item 独立成功、失败、取消和重试。
当目标是自建助手库时,item 写入顺序是:
```text
写入目标助手库 -> 生成目标库 Wiki/实体/关系 -> 创建总库沉淀任务 -> 总库实体对齐和统计
```
总库沉淀失败不能让目标助手库写入结果消失,但 item 必须标记为 `partial_failed`,并允许单独重试“沉淀到总库”。
目标知识库需要通过 adapter 写入:
| 目标类型 | 写入策略 | Wiki | 关系/图谱 |
@@ -202,7 +212,7 @@ items = selected_skills x selected_targets
| WeKnora FAQ 知识库 | 把归纳结果转换为 FAQ 条目、来源文档和关系摘要 | 不写或只写 sidecar Wiki | 关系作为 FAQ metadata、projection 文档和图谱边 |
| 系统会话存档知识库 | 把技能归纳结果作为系统会话归档条目写入 | sidecar Wiki 展示 | 关系作为归档 metadata 和图谱边 |
| 只读 WeKnora 知识库 | 不修改远端原库;写本地 sidecar artifact,并生成待推送状态 | sidecar Wiki 展示 | sidecar graph + 待推送 Neo4j plan |
| 助手知识库 | 写入自研底库、实体对齐、生成 Wiki 页面版本 | 写自研 Wiki | 写自研实体/关系表 |
| 助手知识库 | 写入指定助手库、实体对齐、生成 Wiki 页面版本;同时异步沉淀到知识梳理总库 | 写自研 Wiki | 写自研实体/关系表;总库做跨库实体对齐 |
只读目标说明:
@@ -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")}
+24 -5
View File
@@ -1,5 +1,6 @@
import { apiFetch } from "../api";
import { getFileName } from "../utils/files";
import { apiFetch } from "../api";
import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx";
import { getFileName } from "../utils/files";
import { urlOfArtifact } from "./utils";
@@ -39,7 +40,7 @@ async function errorMessageFromResponse(res: Response): Promise<string> {
return res.statusText || "Request failed";
}
export async function downloadArtifactFile({
export async function downloadArtifactFile({
filepath,
threadId,
isMock,
@@ -73,8 +74,26 @@ export async function downloadArtifactFile({
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
export async function downloadArtifactMarkdownAsWord({
filepath,
threadId,
}: {
filepath: string;
threadId: string;
}): Promise<void> {
const url = urlOfArtifact({ filepath, threadId, download: false });
const res = await apiFetch(url);
if (!res.ok) {
throw new Error(await errorMessageFromResponse(res));
}
const fileName = getFileName(filepath);
const title = fileName.replace(/\.(md|markdown)$/i, "");
await downloadMarkdownAsDocx(await res.text(), fileName, title);
}
export async function openArtifactInNewTab({
filepath,
@@ -9,11 +9,15 @@ export type ArtifactLibraryKind =
| "archive"
| "other";
export type ArtifactLibrarySource = "all" | "general_qa" | "agent";
export interface ArtifactLibraryItem {
threadId: string;
threadTitle: string;
agentId: string | null;
agentName: string | null;
threadType: string | null;
sourceType: Exclude<ArtifactLibrarySource, "all">;
path: string;
name: string;
sizeBytes: number;
@@ -32,6 +36,7 @@ export interface ArtifactLibraryResponse {
export async function fetchArtifactLibrary({
query,
fileKind,
sourceType,
offset,
limit,
refresh = false,
@@ -39,6 +44,7 @@ export async function fetchArtifactLibrary({
}: {
query?: string;
fileKind: ArtifactLibraryKind;
sourceType: ArtifactLibrarySource;
offset: number;
limit: number;
refresh?: boolean;
@@ -46,6 +52,7 @@ export async function fetchArtifactLibrary({
}): Promise<ArtifactLibraryResponse> {
const search = new URLSearchParams({
file_kind: fileKind,
source_type: sourceType,
offset: String(offset),
limit: String(limit),
});
@@ -72,7 +79,9 @@ export async function fetchArtifactLibrary({
thread_id: string;
thread_title: string;
agent_id?: string | null;
agent_name?: string | null;
thread_type?: string | null;
source_type?: Exclude<ArtifactLibrarySource, "all">;
path: string;
name: string;
size_bytes: number;
@@ -90,7 +99,9 @@ export async function fetchArtifactLibrary({
threadId: item.thread_id,
threadTitle: item.thread_title,
agentId: item.agent_id ?? null,
agentName: item.agent_name ?? item.agent_id ?? null,
threadType: item.thread_type ?? null,
sourceType: item.source_type ?? (item.agent_id ? "agent" : "general_qa"),
path: item.path,
name: item.name,
sizeBytes: item.size_bytes,
+15 -9
View File
@@ -1293,15 +1293,21 @@ export function useThreadStream({
...context,
...extraContext,
};
const selectedLlmWikiIds = Array.isArray(
mergedContext.llmwiki_knowledge_base_ids,
)
? mergedContext.llmwiki_knowledge_base_ids
: [];
const ragModeEnabled =
localSettings.display.ragModeEnabled ||
isThreadRagModeLocked(threadId) ||
selectedLlmWikiIds.length > 0;
const selectedLlmWikiIds = Array.isArray(
mergedContext.llmwiki_knowledge_base_ids,
)
? mergedContext.llmwiki_knowledge_base_ids
: [];
const selectedAssistantKnowledgeIds = Array.isArray(
mergedContext.assistant_knowledge_base_ids,
)
? mergedContext.assistant_knowledge_base_ids
: [];
const ragModeEnabled =
localSettings.display.ragModeEnabled ||
isThreadRagModeLocked(threadId) ||
selectedLlmWikiIds.length > 0 ||
selectedAssistantKnowledgeIds.length > 0;
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
+2
View File
@@ -27,6 +27,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
knowledge_rag_enabled?: boolean;
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
llmwiki_knowledge_base_ids?: string[];
/** Assistant knowledge-base ids selected for this conversation. */
assistant_knowledge_base_ids?: string[];
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */
execution_profile?: "single_round_rag" | "multi_round_rag";
agent_id?: string;
+16 -2
View File
@@ -9,6 +9,7 @@ import {
SparklesIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -29,10 +30,22 @@ import { SkillAddressesPanel } from "./SkillAddressesPage";
const PAGE_SIZE = 10;
export default function AdminSkillsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const { skills, isLoading, error } = useAllSkillsAdmin(true);
const knowledgeBindings = useSkillKnowledgeBindings();
const activeTab = useMemo(() => {
const tab = searchParams.get("tab");
return tab === "addresses" || tab === "knowledge" ? tab : "skills";
}, [searchParams]);
const changeTab = (value: string) => {
const next = new URLSearchParams(searchParams);
if (value === "skills") next.delete("tab");
else next.set("tab", value);
setSearchParams(next, { replace: true });
};
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
@@ -60,7 +73,8 @@ export default function AdminSkillsPage() {
<WorkspaceHeader />
<WorkspaceBody>
<Tabs
defaultValue="skills"
value={activeTab}
onValueChange={changeTab}
className="flex size-full min-h-0 flex-col gap-0"
>
<div className="flex items-center justify-between gap-4 border-b px-6 pt-4">
@@ -77,7 +91,7 @@ export default function AdminSkillsPage() {
</TabsTrigger>
<TabsTrigger value="knowledge">
<DatabaseZap />
技能知识归纳
技能到知识库管理
</TabsTrigger>
</TabsList>
</div>
+71 -12
View File
@@ -92,6 +92,26 @@ function readPendingKnowledgeSelection(agentId: string | undefined): string[] |
}
}
function readPendingAssistantKnowledgeSelection(agentId: string | undefined): string[] | null {
if (typeof window === "undefined" || !agentId) return null;
try {
const raw = sessionStorage.getItem("pendingAgentMessage");
if (!raw) return null;
const parsed = JSON.parse(raw) as {
agentId?: string;
assistantKnowledgeBaseIds?: unknown;
};
if (parsed.agentId !== agentId || !Array.isArray(parsed.assistantKnowledgeBaseIds)) {
return null;
}
return parsed.assistantKnowledgeBaseIds
.map((item) => String(item).trim())
.filter(Boolean);
} catch {
return null;
}
}
function stringifyForHiddenContext(value: unknown): string {
if (typeof value === "string") return value;
try {
@@ -270,11 +290,17 @@ export default function AgentChatPage({
const pendingKnowledgeSelection = useRef(
readPendingKnowledgeSelection(agent_id),
);
const pendingAssistantKnowledgeSelection = useRef(
readPendingAssistantKnowledgeSelection(agent_id),
);
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>(
() => pendingKnowledgeSelection.current ?? [],
);
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>(
() => pendingAssistantKnowledgeSelection.current ?? [],
);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
() => pendingKnowledgeSelection.current !== null,
() => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
);
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
const knowledgeSelectionThread = useRef<string | null>(null);
@@ -285,6 +311,10 @@ export default function AgentChatPage({
setSelectedKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
const changeAssistantKnowledgeSelection = useCallback((ids: string[]) => {
setSelectedAssistantKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
useEffect(() => {
activeThreadIdRef.current = threadId;
@@ -294,17 +324,23 @@ export default function AgentChatPage({
if (knowledgeSelectionAgent.current === agent_id) return;
knowledgeSelectionAgent.current = agent_id;
const pending = readPendingKnowledgeSelection(agent_id);
const pendingAssistant = readPendingAssistantKnowledgeSelection(agent_id);
pendingKnowledgeSelection.current = pending;
pendingAssistantKnowledgeSelection.current = pendingAssistant;
knowledgeSelectionThread.current = null;
setSelectedKnowledgeBaseIds(pending ?? []);
setKnowledgeSelectionReady(pending !== null);
setSelectedAssistantKnowledgeBaseIds(pendingAssistant ?? []);
setKnowledgeSelectionReady(pending !== null || pendingAssistant !== null);
}, [agent_id]);
useEffect(() => {
if (isNewThread) {
if (knowledgeSelectionThread.current !== "__new__") {
setSelectedKnowledgeBaseIds(pendingKnowledgeSelection.current ?? []);
setKnowledgeSelectionReady(pendingKnowledgeSelection.current !== null);
setSelectedAssistantKnowledgeBaseIds(pendingAssistantKnowledgeSelection.current ?? []);
setKnowledgeSelectionReady(
pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
);
}
knowledgeSelectionThread.current = "__new__";
setKnowledgeSelectionLoading(false);
@@ -319,18 +355,29 @@ export default function AgentChatPage({
if (knowledgeSelectionThread.current !== threadId) return;
const metadata = record.metadata as Record<string, unknown> | undefined;
const raw = metadata?.llmwiki_knowledge_base_ids;
const assistantRaw = metadata?.assistant_knowledge_base_ids;
const llmwikiIds = Array.isArray(raw)
? raw.map((item) => String(item).trim()).filter(Boolean)
: [];
const assistantIds = Array.isArray(assistantRaw)
? assistantRaw.map((item) => String(item).trim()).filter(Boolean)
: [];
if (Array.isArray(raw)) {
setSelectedKnowledgeBaseIds(
raw.map((item) => String(item).trim()).filter(Boolean),
);
setKnowledgeSelectionReady(true);
setSelectedKnowledgeBaseIds(llmwikiIds);
} else {
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
if (Array.isArray(assistantRaw)) {
setSelectedAssistantKnowledgeBaseIds(assistantIds);
} else {
setSelectedAssistantKnowledgeBaseIds([]);
}
setKnowledgeSelectionReady(Array.isArray(raw) || Array.isArray(assistantRaw));
})
.catch(() => {
if (knowledgeSelectionThread.current === threadId) {
setSelectedKnowledgeBaseIds([]);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
})
@@ -353,10 +400,13 @@ export default function AgentChatPage({
() => ({
...(taskId ? taskThreadMetadata(taskContext) : {}),
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: {}),
}),
[knowledgeSelectionReady, selectedKnowledgeBaseIds, taskContext, taskId],
[knowledgeSelectionReady, selectedAssistantKnowledgeBaseIds, selectedKnowledgeBaseIds, taskContext, taskId],
);
const [thread, sendMessage] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
@@ -365,7 +415,10 @@ export default function AgentChatPage({
...settings.context,
agent_id,
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: {}),
},
// 任务闸门:新建 thread 打 metadata.taskId 标签,供任务对话列表(1:N)按任务过滤;
@@ -713,9 +766,13 @@ export default function AgentChatPage({
source="agent"
agentId={agent_id}
value={selectedKnowledgeBaseIds}
assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection}
autoSelectAll={
isNewThread && pendingKnowledgeSelection.current === null
isNewThread &&
pendingKnowledgeSelection.current === null &&
pendingAssistantKnowledgeSelection.current === null
}
disabled={isDemoMode || knowledgeSelectionLoading}
label="知识空间"
@@ -781,7 +838,9 @@ export default function AgentChatPage({
source="agent"
agentId={agent_id}
value={selectedKnowledgeBaseIds}
assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection}
disabled={isDemoMode || knowledgeSelectionLoading}
readOnly
label="知识空间"
+169 -43
View File
@@ -3,12 +3,15 @@
import { useQuery } from "@tanstack/react-query";
import {
ArrowDownWideNarrow,
Bot,
ChevronLeft,
ChevronRight,
Download,
FileText,
FileX2,
Library,
Loader2,
MessagesSquare,
MessageSquareShare,
RefreshCw,
Search,
@@ -20,17 +23,28 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { downloadArtifactFile } from "@/core/artifacts/download";
import {
downloadArtifactFile,
downloadArtifactMarkdownAsWord,
} from "@/core/artifacts/download";
import {
fetchArtifactLibrary,
type ArtifactLibraryItem,
type ArtifactLibraryKind,
type ArtifactLibrarySource,
} from "@/core/artifacts/library";
import { useI18n } from "@/core/i18n/hooks";
import { bootstrapResumePath, pathOfThread } from "@/core/threads/utils";
@@ -53,6 +67,28 @@ const FILTERS: Array<{ value: ArtifactLibraryKind; label: string }> = [
{ value: "other", label: "其他" },
];
const SOURCE_FILTERS: Array<{
value: ArtifactLibrarySource;
label: string;
}> = [
{ value: "all", label: "全部来源" },
{ value: "general_qa", label: "通用问答" },
{ value: "agent", label: "智能体" },
];
type DownloadFormat = "original" | "word";
function isMarkdownArtifact(name: string): boolean {
return /\.(md|markdown)$/i.test(name);
}
function artifactDownloadKey(
item: ArtifactLibraryItem,
format: DownloadFormat,
): string {
return `${item.threadId}:${item.path}:${format}`;
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
@@ -83,12 +119,19 @@ function FileRow({
onDownload,
}: {
item: ArtifactLibraryItem;
downloading: boolean;
onDownload: (item: ArtifactLibraryItem) => void;
downloading: DownloadFormat | null;
onDownload: (item: ArtifactLibraryItem, format: DownloadFormat) => void;
}) {
const sourcePath = sourceConversationPath(item);
const displayName = desensitize(item.name);
const sourceTitle = desensitize(item.threadTitle || "新对话");
const sourceLabel = item.sourceType === "agent" ? "智能体" : "通用问答";
const sourceName =
item.sourceType === "agent"
? desensitize(item.agentName || item.agentId || "未知智能体")
: sourceLabel;
const SourceIcon = item.sourceType === "agent" ? Bot : MessagesSquare;
const busy = downloading !== null;
return (
<tr className="group border-b transition-colors hover:bg-muted/35">
@@ -106,7 +149,8 @@ function FileRow({
{displayName}
</span>
<span className="mt-1 block truncate text-xs text-muted-foreground lg:hidden">
{getFileExtensionDisplayName(item.name)} · 来自「{sourceTitle}」
{getFileExtensionDisplayName(item.name)} · {sourceName} · 来自「
{sourceTitle}」
</span>
<span className="mt-1 hidden text-xs text-muted-foreground lg:block">
{getFileExtensionDisplayName(item.name)} 文件
@@ -115,13 +159,24 @@ function FileRow({
</Link>
</td>
<td className="hidden min-w-0 px-3 py-3 lg:table-cell">
<Link
href={sourcePath}
className="block truncate text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
title={`打开原对话:${sourceTitle}`}
>
{sourceTitle}
</Link>
<div className="flex min-w-0 items-center gap-2.5">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<SourceIcon className="size-4" />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">
{sourceName}
</span>
<Link
href={sourcePath}
className="block truncate text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
title={`打开原对话:${sourceTitle}`}
>
{item.sourceType === "agent" ? "智能体 · " : ""}
{sourceTitle}
</Link>
</span>
</div>
</td>
<td className="whitespace-nowrap px-3 py-3 text-sm text-muted-foreground">
{formatDateTime(item.modifiedAt)}
@@ -136,17 +191,36 @@ function FileRow({
size="icon"
variant="ghost"
className="size-8"
disabled={downloading}
onClick={() => onDownload(item)}
title={`下载 ${displayName}`}
aria-label={`下载 ${displayName}`}
disabled={busy}
onClick={() => onDownload(item, "original")}
title={`下载原文件:${displayName}`}
aria-label={`下载原文件:${displayName}`}
>
{downloading ? (
{downloading === "original" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
</Button>
{isMarkdownArtifact(item.name) && (
<Button
type="button"
size="sm"
variant="ghost"
className="h-8 gap-1 px-2 text-xs"
disabled={busy}
onClick={() => onDownload(item, "word")}
title={`下载为 Word:${displayName}`}
aria-label={`下载为 Word:${displayName}`}
>
{downloading === "word" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<FileText className="size-4" />
)}
Word
</Button>
)}
<Button
asChild
size="icon"
@@ -189,6 +263,7 @@ export default function ArtifactLibraryPage() {
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [fileKind, setFileKind] = useState<ArtifactLibraryKind>("all");
const [sourceType, setSourceType] = useState<ArtifactLibrarySource>("all");
const [page, setPage] = useState(1);
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
const forceRefreshRef = useRef(false);
@@ -204,17 +279,18 @@ export default function ArtifactLibraryPage() {
useEffect(() => {
setPage(1);
}, [debouncedSearch, fileKind]);
}, [debouncedSearch, fileKind, sourceType]);
const offset = (page - 1) * PAGE_SIZE;
const libraryQuery = useQuery({
queryKey: ["artifact-library", debouncedSearch, fileKind, page],
queryKey: ["artifact-library", debouncedSearch, fileKind, sourceType, page],
queryFn: async ({ signal }) => {
const refresh = forceRefreshRef.current;
try {
return await fetchArtifactLibrary({
query: debouncedSearch,
fileKind,
sourceType,
offset,
limit: PAGE_SIZE,
refresh,
@@ -238,19 +314,39 @@ export default function ArtifactLibraryPage() {
}, [page, totalPages]);
const itemKeys = useMemo(
() => new Set(items.map((item) => `${item.threadId}:${item.path}`)),
() =>
new Set(
items.flatMap((item) => [
artifactDownloadKey(item, "original"),
artifactDownloadKey(item, "word"),
]),
),
[items],
);
useEffect(() => {
if (downloadingKey && !itemKeys.has(downloadingKey)) setDownloadingKey(null);
}, [downloadingKey, itemKeys]);
const handleDownload = async (item: ArtifactLibraryItem) => {
const key = `${item.threadId}:${item.path}`;
const handleDownload = async (
item: ArtifactLibraryItem,
format: DownloadFormat,
) => {
const key = artifactDownloadKey(item, format);
if (downloadingKey) return;
setDownloadingKey(key);
try {
await downloadArtifactFile({ filepath: item.path, threadId: item.threadId });
if (format === "word") {
await downloadArtifactMarkdownAsWord({
filepath: item.path,
threadId: item.threadId,
});
toast.success("Word 文件已生成");
} else {
await downloadArtifactFile({
filepath: item.path,
threadId: item.threadId,
});
}
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : "下载失败");
@@ -298,26 +394,49 @@ export default function ArtifactLibraryPage() {
<Input
type="search"
className="h-11 pl-9"
placeholder="搜索文件名或来源对话"
placeholder="搜索文件名、来源对话或智能体名称"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-1" aria-label="文件类型筛选">
{FILTERS.map((filter) => (
<Button
key={filter.value}
type="button"
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-wrap gap-1" aria-label="文件类型筛选">
{FILTERS.map((filter) => (
<Button
key={filter.value}
type="button"
size="sm"
variant={fileKind === filter.value ? "secondary" : "ghost"}
aria-pressed={fileKind === filter.value}
onClick={() => setFileKind(filter.value)}
>
{filter.label}
</Button>
))}
</div>
<Select
value={sourceType}
onValueChange={(value) =>
setSourceType(value as ArtifactLibrarySource)
}
>
<SelectTrigger
size="sm"
variant={fileKind === filter.value ? "secondary" : "ghost"}
aria-pressed={fileKind === filter.value}
onClick={() => setFileKind(filter.value)}
className="w-32"
aria-label="筛选文件来源"
>
{filter.label}
</Button>
))}
<SelectValue />
</SelectTrigger>
<SelectContent>
{SOURCE_FILTERS.map((filter) => (
<SelectItem key={filter.value} value={filter.value}>
{filter.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<ArrowDownWideNarrow className="size-4" />
@@ -337,11 +456,11 @@ export default function ArtifactLibraryPage() {
<table className="w-full table-fixed border-collapse">
<thead>
<tr className="border-b text-left text-xs font-normal text-muted-foreground">
<th className="w-[48%] py-3 pr-4 pl-1 font-normal lg:w-[38%]">文件名</th>
<th className="hidden w-[27%] px-3 py-3 font-normal lg:table-cell">来源对话</th>
<th className="w-[34%] px-3 py-3 font-normal md:w-[22%] lg:w-[17%]">生成时间</th>
<th className="hidden w-[13%] px-3 py-3 font-normal md:table-cell">大小</th>
<th className="w-[18%] py-3 pr-1 pl-3 text-right font-normal md:w-[10%]">
<th className="w-[48%] py-3 pr-4 pl-1 font-normal lg:w-[35%]">文件名</th>
<th className="hidden w-[25%] px-3 py-3 font-normal lg:table-cell">来源</th>
<th className="w-[34%] px-3 py-3 font-normal md:w-[22%] lg:w-[16%]">生成时间</th>
<th className="hidden w-[13%] px-3 py-3 font-normal lg:w-[10%] md:table-cell">大小</th>
<th className="w-[18%] py-3 pr-1 pl-3 text-right font-normal md:w-[17%] lg:w-[14%]">
<span className="sr-only">操作</span>
</th>
</tr>
@@ -352,12 +471,19 @@ export default function ArtifactLibraryPage() {
) : (
items.map((item) => {
const key = `${item.threadId}:${item.path}`;
const activeDownload = downloadingKey?.startsWith(`${key}:`)
? downloadingKey.endsWith(":word")
? "word"
: "original"
: null;
return (
<FileRow
key={key}
item={item}
downloading={downloadingKey === key}
onDownload={(target) => void handleDownload(target)}
downloading={activeDownload}
onDownload={(target, format) =>
void handleDownload(target, format)
}
/>
);
})
@@ -388,7 +514,7 @@ export default function ArtifactLibraryPage() {
<FileX2 className="size-9 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
{debouncedSearch || fileKind !== "all"
{debouncedSearch || fileKind !== "all" || sourceType !== "all"
? "没有找到匹配的文件"
: "还没有生成文件"}
</p>
+409 -35
View File
@@ -1,74 +1,448 @@
import { Database, FileUp, History, Loader2, RefreshCw, Search, ShieldCheck, Trash2 } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { BookOpen, Database, Download, Loader2, Plus, RefreshCw, Search, ShieldCheck, Trash2, Upload } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { Textarea } from "@/components/ui/textarea";
import { KnowledgeProductNav } from "@/components/workspace/knowledge-product-nav";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { getStoredAuth } from "@/core/auth";
import {
listAssistantWikiRevisions,
type AssistantKnowledgeBase,
type AssistantWikiPage,
type AssistantWikiRevision,
useAssistantImportJobs,
useAssistantKnowledgeActions,
useAssistantKnowledgeBase,
useAssistantKnowledgeBases,
useAssistantKnowledgeSources,
useAssistantWikiPages,
} from "@/strategy-components/api/assistant-knowledge";
function baseSubtitle(base: AssistantKnowledgeBase) {
if (base.is_global) return "全库沉淀总库 · 所有 WeKnora 导入数据都会同步进入这里并做实体对齐";
return base.description || "自建助手知识库 · 可上传普通知识库导出的 Wiki 向量包";
}
function EmptyInitializeCard({
isAdmin,
onInitialize,
pending,
}: {
isAdmin: boolean;
onInitialize: () => void;
pending: boolean;
}) {
return (
<div className="flex flex-1 items-center justify-center p-8">
<div className="max-w-lg rounded-xl border bg-card p-8 text-center">
<Database className="mx-auto mb-4 size-10 text-primary" />
<h1 className="text-xl font-semibold">初始化助手知识库</h1>
<p className="mt-2 text-sm text-muted-foreground">
未配置 WeKnora 时会默认展示助手知识库。首次使用需要创建“知识梳理总库”,后续从 WeKnora 导入的数据都会沉淀到这里。
</p>
{isAdmin ? (
<Button className="mt-5" disabled={pending} onClick={onInitialize}>
{pending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Database className="mr-2 size-4" />}
创建知识梳理总库
</Button>
) : (
<p className="mt-5 text-sm">请联系管理员初始化。</p>
)}
</div>
</div>
);
}
export default function AssistantKnowledgePage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const [query, setQuery] = useState("");
const [selectedBaseId, setSelectedBaseId] = useState<string | null>(null);
const [selected, setSelected] = useState<AssistantWikiPage | null>(null);
const [revisions, setRevisions] = useState<AssistantWikiRevision[]>([]);
const fileRef = useRef<HTMLInputElement>(null);
const base = useAssistantKnowledgeBase();
const pages = useAssistantWikiPages(query);
const sources = useAssistantKnowledgeSources();
const jobs = useAssistantImportJobs();
const [createOpen, setCreateOpen] = useState(false);
const [createDraft, setCreateDraft] = useState({ name: "", description: "" });
const packageInputRef = useRef<HTMLInputElement | null>(null);
const bases = useAssistantKnowledgeBases();
const actions = useAssistantKnowledgeActions();
useEffect(() => {
const values = bases.data ?? [];
if (!values.length) {
setSelectedBaseId(null);
return;
}
if (selectedBaseId && values.some((base) => base.id === selectedBaseId)) return;
setSelectedBaseId((values.find((base) => base.is_global) ?? values[0]).id);
}, [bases.data, selectedBaseId]);
useEffect(() => {
setSelected(null);
}, [selectedBaseId]);
const selectedBase = useMemo(
() => (bases.data ?? []).find((base) => base.id === selectedBaseId) ?? null,
[bases.data, selectedBaseId],
);
const pages = useAssistantWikiPages(query, selectedBaseId);
const sources = useAssistantKnowledgeSources(selectedBaseId);
const jobs = useAssistantImportJobs(selectedBaseId);
const activePage = selected ?? pages.data?.[0] ?? null;
const counts = useMemo(
() => ({
pages: pages.data?.length ?? selectedBase?.page_count ?? 0,
sources: sources.data?.length ?? selectedBase?.source_count ?? 0,
jobs: jobs.data?.length ?? 0,
}),
[jobs.data, pages.data, selectedBase?.page_count, selectedBase?.source_count, sources.data],
);
const counts = useMemo(() => ({ pages: pages.data?.length ?? 0, sources: sources.data?.length ?? 0, jobs: jobs.data?.length ?? 0 }), [pages.data, sources.data, jobs.data]);
const initializeGlobal = () => {
actions.initialize.mutate("知识梳理总库", {
onSuccess: (base) => {
setSelectedBaseId(base.id);
toast.success("知识梳理总库已创建");
},
onError: (error) => toast.error(error.message),
});
};
if (base.isLoading) return <div className="flex size-full flex-col"><KnowledgeProductNav /><div className="flex flex-1 items-center justify-center"><Loader2 className="animate-spin" /></div></div>;
if (!base.data) {
return <div className="flex size-full flex-col"><KnowledgeProductNav /><div className="flex flex-1 items-center justify-center p-8"><div className="max-w-lg rounded-xl border bg-card p-8 text-center"><Database className="mx-auto mb-4 size-10 text-primary" /><h1 className="text-xl font-semibold">创建“知识梳理”助手知识库</h1><p className="mt-2 text-sm text-muted-foreground">无需配置普通知识库服务。创建后所有用户可查看 Wiki 与搜索,只有管理员可导入和维护。</p>{isAdmin ? <Button className="mt-5" disabled={actions.initialize.isPending} onClick={() => actions.initialize.mutate("知识梳理")}>创建知识梳理库</Button> : <p className="mt-5 text-sm">请联系管理员初始化。</p>}</div></div></div>;
}
const uploadFiles = async (files: FileList | null) => {
if (!files?.length) return;
const createBase = async () => {
const name = createDraft.name.trim();
if (!name) {
toast.error("请输入助手知识库名称");
return;
}
try {
await actions.upload.mutateAsync(Array.from(files));
toast.success("文件已安全扫描并导入");
const base = await actions.createBase.mutateAsync({
name,
description: createDraft.description.trim(),
});
setSelectedBaseId(base.id);
setCreateDraft({ name: "", description: "" });
setCreateOpen(false);
toast.success("助手知识库已创建");
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
if (fileRef.current) fileRef.current.value = "";
}
};
const openHistory = async () => {
if (!activePage) return;
setRevisions(await listAssistantWikiRevisions(activePage.slug));
const uploadPackage = async (file: File | null | undefined) => {
if (!file) return;
try {
await actions.uploadWeKnoraPackage.mutateAsync({
file,
assistantBaseId: selectedBaseId,
});
toast.success("导出包已上传,后台正在异步导入");
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
if (packageInputRef.current) packageInputRef.current.value = "";
}
};
if (bases.isLoading) {
return (
<div className="flex size-full flex-col">
<KnowledgeProductNav />
<div className="flex flex-1 items-center justify-center">
<Loader2 className="animate-spin" />
</div>
</div>
);
}
if (!bases.data?.length) {
return (
<div className="flex size-full flex-col">
<KnowledgeProductNav />
<EmptyInitializeCard
isAdmin={isAdmin}
pending={actions.initialize.isPending}
onInitialize={initializeGlobal}
/>
</div>
);
}
return (
<div className="flex size-full min-h-0 flex-col bg-background">
<KnowledgeProductNav />
<header className="flex items-center gap-4 border-b px-6 py-4"><div><h1 className="text-xl font-semibold">{base.data.name}</h1><p className="text-xs text-muted-foreground">全局助手知识库 · 普通用户只读</p></div><Badge variant="outline"><ShieldCheck className="mr-1 size-3" />实体对齐与版本管理</Badge><div className="ml-auto flex gap-2">{isAdmin && <><input ref={fileRef} className="hidden" type="file" multiple onChange={(event) => void uploadFiles(event.target.files)} /><Button variant="outline" onClick={() => fileRef.current?.click()}><FileUp />导入本地文件</Button></>}<Button variant="outline" disabled={!activePage} onClick={() => void openHistory()}><History />页面版本</Button></div></header>
<Tabs defaultValue="wiki" className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center gap-4 border-b px-6"><TabsList className="my-2"><TabsTrigger value="wiki">Wiki({counts.pages})</TabsTrigger><TabsTrigger value="sources">来源({counts.sources})</TabsTrigger><TabsTrigger value="jobs">导入记录({counts.jobs})</TabsTrigger><TabsTrigger value="revisions">版本({revisions.length})</TabsTrigger></TabsList><div className="relative ml-auto w-72"><Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /><Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索标题、实体别名和正文" className="pl-8" /></div></div>
<TabsContent value="wiki" className="mt-0 grid min-h-0 flex-1 grid-cols-[300px_1fr]"><ScrollArea className="border-r"><div className="space-y-1 p-3">{pages.data?.map((page) => <button key={page.id} type="button" onClick={() => setSelected(page)} className={`w-full rounded-md px-3 py-2 text-left ${activePage?.id === page.id ? "bg-primary/10 text-primary" : "hover:bg-muted"}`}><div className="truncate text-sm font-medium">{page.title}</div><div className="mt-1 flex items-center gap-2 text-[11px] text-muted-foreground"><span>{page.page_type}</span><span>{new Date(page.updated_at).toLocaleString()}</span></div></button>)}</div></ScrollArea><ScrollArea><article className="mx-auto max-w-5xl p-8">{activePage ? <><h1 className="mb-1 text-2xl font-semibold">{activePage.title}</h1><p className="mb-6 text-xs text-muted-foreground">{activePage.slug}</p><MarkdownContent content={activePage.content_markdown} isLoading={false} rehypePlugins={[]} /></> : <p className="text-sm text-muted-foreground">暂无 Wiki 页面。</p>}</article></ScrollArea></TabsContent>
<TabsContent value="sources" className="mt-0 min-h-0 flex-1 overflow-auto p-6"><div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">{sources.data?.map((source) => <div key={source.id} className="rounded-lg border bg-card p-4"><div className="flex items-center gap-2"><Database className="size-4" /><span className="font-medium">{source.source_name}</span><Badge variant="outline" className="ml-auto">{source.source_type}</Badge></div><p className="mt-3 text-xs text-muted-foreground">业务键:{source.source_key}</p>{isAdmin && <div className="mt-4 flex justify-end gap-2"><Button size="sm" variant="outline" disabled={source.source_type === "local_file" || actions.reimportSource.isPending} onClick={() => actions.reimportSource.mutate(source.id, { onSuccess: () => toast.success("已发起重新导入"), onError: (error) => toast.error(error.message) })}><RefreshCw />重新导入</Button><Button size="sm" variant="destructive" disabled={actions.deleteSource.isPending} onClick={() => { if (window.confirm(`确认删除来源“${source.source_name}”的贡献?仍被其他来源引用的知识会保留。`)) actions.deleteSource.mutate(source.id, { onSuccess: () => toast.success("来源贡献已删除"), onError: (error) => toast.error(error.message) }); }}><Trash2 />删除来源</Button></div>}</div>)}</div></TabsContent>
<TabsContent value="jobs" className="mt-0 min-h-0 flex-1 overflow-auto p-6"><div className="space-y-3">{jobs.data?.map((job) => <div key={job.id} className="flex items-center gap-3 rounded-lg border bg-card p-4"><Badge>{job.status}</Badge><span className="text-sm">{job.trigger}</span><span className="text-xs text-muted-foreground">{Object.entries(job.counts || {}).map(([key, value]) => `${key} ${value}`).join(" · ")}</span><span className="ml-auto text-xs">{new Date(job.created_at).toLocaleString()}</span></div>)}</div></TabsContent>
<TabsContent value="revisions" className="mt-0 min-h-0 flex-1 overflow-auto p-6"><div className="space-y-3">{revisions.map((revision) => <div key={revision.id} className="flex items-center gap-3 rounded-lg border bg-card p-4"><Badge variant="outline">v{revision.revision_no}</Badge><span className="text-sm">{revision.change_type}</span><span className="text-xs text-muted-foreground">{revision.summary}</span><span className="ml-auto text-xs">{new Date(revision.created_at).toLocaleString()}</span>{isAdmin && activePage && revision.id !== activePage.current_revision_id && <Button size="sm" variant="outline" onClick={() => actions.rollback.mutate({ slug: activePage.slug, revisionId: revision.id })}>回滚到此版本</Button>}</div>)}{!revisions.length && <p className="text-sm text-muted-foreground">请先在 Wiki 页选择页面并点击“页面版本”。</p>}</div></TabsContent>
</Tabs>
<div className="grid min-h-0 flex-1 grid-cols-[280px_1fr]">
<aside className="flex min-h-0 flex-col border-r bg-muted/20">
<div className="border-b p-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold">助手知识库</h2>
<p className="mt-1 text-xs text-muted-foreground">总库置顶,自建库可上传 WeKnora 导出包</p>
</div>
{isAdmin && (
<Button size="icon" variant="outline" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
</Button>
)}
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-2 p-3">
{(bases.data ?? []).map((base) => (
<button
key={base.id}
type="button"
onClick={() => setSelectedBaseId(base.id)}
className={`w-full rounded-lg border px-3 py-3 text-left transition ${
selectedBaseId === base.id ? "border-primary bg-primary/10 text-primary" : "bg-card hover:bg-muted"
}`}
>
<div className="flex items-center gap-2">
<Database className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate text-sm font-medium">{base.name}</span>
{base.is_global && <Badge variant="outline" className="text-[10px]">总库</Badge>}
</div>
<div className="mt-2 flex gap-3 text-[11px] text-muted-foreground">
<span>{base.page_count ?? 0} 页</span>
<span>{base.source_count ?? 0} 来源</span>
<span>{base.entity_count ?? 0} 实体</span>
</div>
</button>
))}
</div>
</ScrollArea>
</aside>
<main className="flex min-h-0 flex-col">
<header className="flex items-center gap-4 border-b px-6 py-4">
<div className="min-w-0">
<h1 className="truncate text-xl font-semibold">{selectedBase?.name}</h1>
<p className="mt-1 truncate text-xs text-muted-foreground">
{selectedBase ? baseSubtitle(selectedBase) : "请选择助手知识库"}
</p>
</div>
<Badge variant="outline">
<ShieldCheck className="mr-1 size-3" />
实体对齐
</Badge>
{isAdmin && (
<div className="ml-auto flex items-center gap-2">
<input
ref={packageInputRef}
type="file"
accept=".jsonl,.ndjson,application/x-ndjson,application/json"
className="hidden"
onChange={(event) => void uploadPackage(event.target.files?.[0])}
/>
<Button
variant="outline"
disabled={actions.uploadWeKnoraPackage.isPending || !selectedBaseId}
onClick={() => packageInputRef.current?.click()}
>
{actions.uploadWeKnoraPackage.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Upload className="mr-2 size-4" />
)}
上传 WeKnora 导出包
</Button>
</div>
)}
</header>
<Tabs defaultValue="wiki" className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center gap-4 border-b px-6">
<TabsList className="my-2">
<TabsTrigger value="wiki">Wiki({counts.pages})</TabsTrigger>
<TabsTrigger value="sources">来源({counts.sources})</TabsTrigger>
<TabsTrigger value="jobs">导入记录({counts.jobs})</TabsTrigger>
</TabsList>
<div className="relative ml-auto w-72">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索标题、实体别名和正文"
className="pl-8"
/>
</div>
</div>
<TabsContent value="wiki" className="mt-0 grid min-h-0 flex-1 grid-cols-[300px_1fr]">
<ScrollArea className="border-r">
<div className="space-y-1 p-3">
{pages.isLoading && <Loader2 className="m-4 size-5 animate-spin" />}
{pages.data?.map((page) => (
<button
key={page.id}
type="button"
onClick={() => setSelected(page)}
className={`w-full rounded-md px-3 py-2 text-left ${
activePage?.id === page.id ? "bg-primary/10 text-primary" : "hover:bg-muted"
}`}
>
<div className="truncate text-sm font-medium">{page.title}</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{page.page_type}</span>
<span>{new Date(page.updated_at).toLocaleString()}</span>
</div>
</button>
))}
{!pages.isLoading && !pages.data?.length && (
<div className="p-4 text-xs text-muted-foreground">
暂无 Wiki 页面。请先到普通知识库详情页下载向量化数据包,再在当前助手知识库上传。
</div>
)}
</div>
</ScrollArea>
<ScrollArea>
<article className="mx-auto max-w-5xl p-8">
{activePage ? (
<>
<h1 className="mb-1 flex items-center gap-2 text-2xl font-semibold">
<BookOpen className="size-5 text-primary" />
{activePage.title}
</h1>
<p className="mb-6 text-xs text-muted-foreground">{activePage.slug}</p>
<MarkdownContent content={activePage.content_markdown} isLoading={false} rehypePlugins={[]} />
</>
) : (
<p className="text-sm text-muted-foreground">暂无 Wiki 页面。</p>
)}
</article>
</ScrollArea>
</TabsContent>
<TabsContent value="sources" className="mt-0 min-h-0 flex-1 overflow-auto p-6">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{sources.data?.map((source) => (
<div key={source.id} className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Database className="size-4" />
<span className="truncate font-medium">{source.source_name}</span>
<Badge variant="outline" className="ml-auto">{source.source_type}</Badge>
</div>
<p className="mt-3 text-xs text-muted-foreground">业务键:{source.source_key}</p>
{isAdmin && (
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
variant="outline"
disabled={source.source_type !== "weknora" || actions.reimportSource.isPending}
onClick={() =>
actions.reimportSource.mutate(source.id, {
onSuccess: () => toast.success("已发起重新导入"),
onError: (error) => toast.error(error.message),
})
}
>
<RefreshCw className="mr-2 size-4" />
重新导入
</Button>
<Button
size="sm"
variant="destructive"
disabled={actions.deleteSource.isPending}
onClick={() => {
if (window.confirm(`确认删除来源“${source.source_name}”的贡献?仍被其他来源引用的知识会保留。`)) {
actions.deleteSource.mutate(source.id, {
onSuccess: () => toast.success("来源贡献已删除"),
onError: (error) => toast.error(error.message),
});
}
}}
>
<Trash2 className="mr-2 size-4" />
删除来源
</Button>
</div>
)}
</div>
))}
{!sources.data?.length && <p className="text-sm text-muted-foreground">暂无来源。</p>}
</div>
</TabsContent>
<TabsContent value="jobs" className="mt-0 min-h-0 flex-1 overflow-auto p-6">
<div className="space-y-3">
{jobs.data?.map((job) => (
<div key={job.id} className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-3">
<Badge>{job.status}</Badge>
<span className="text-sm">{job.trigger}</span>
<span className="text-xs text-muted-foreground">阶段:{job.phase}</span>
<span className="ml-auto text-xs">{new Date(job.created_at).toLocaleString()}</span>
{isAdmin && job.package_download_url && (
<Button asChild size="sm" variant="outline">
<a href={job.package_download_url} target="_blank" rel="noreferrer">
<Download className="mr-2 size-4" />
下载导出包
</a>
</Button>
)}
</div>
<div className="mt-2 text-xs text-muted-foreground">
{Object.entries(job.counts || {}).map(([key, value]) => `${key} ${value}`).join(" · ") || "暂无统计"}
</div>
{job.last_error_message && (
<div className="mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
{job.last_error_message}
</div>
)}
</div>
))}
{!jobs.data?.length && <p className="text-sm text-muted-foreground">暂无导入记录。</p>}
</div>
</TabsContent>
</Tabs>
</main>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>新建助手知识库</DialogTitle>
<DialogDescription>
自建助手知识库用于承接从 WeKnora 普通知识库下载的 Wiki 向量包;导入时同一份数据也会沉淀到“知识梳理总库”。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">名称</label>
<Input
value={createDraft.name}
onChange={(event) => setCreateDraft((prev) => ({ ...prev, name: event.target.value }))}
placeholder="例如:政策资料库"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">描述</label>
<Textarea
value={createDraft.description}
onChange={(event) => setCreateDraft((prev) => ({ ...prev, description: event.target.value }))}
placeholder="可选,用于说明这个助手知识库的范围"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
取消
</Button>
<Button disabled={actions.createBase.isPending} onClick={() => void createBase()}>
{actions.createBase.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Plus className="mr-2 size-4" />}
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+44 -9
View File
@@ -132,6 +132,7 @@ export default function ChatPage() {
// per-thread header toggle so stale localStorage can't re-enable it.
const [knowledgeRagEnabled] = useKnowledgeRagEnabled();
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>([]);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(false);
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
const knowledgeSelectionThread = useRef<string | null>(null);
@@ -140,18 +141,26 @@ export default function ChatPage() {
setSelectedKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
const changeAssistantKnowledgeSelection = useCallback((ids: string[]) => {
setSelectedAssistantKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
const streamContext = useMemo(
() => ({
...settings.context,
knowledge_rag_enabled: knowledgeRagEnabled,
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: {}),
}),
[
settings.context,
knowledgeRagEnabled,
knowledgeSelectionReady,
selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds,
],
);
@@ -190,6 +199,7 @@ export default function ChatPage() {
setHasStartedConversation(false);
setPromptPrefixOn(false);
setSelectedKnowledgeBaseIds([]);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
setKnowledgeSelectionLoading(false);
knowledgeSelectionThread.current = "__new__";
@@ -215,18 +225,29 @@ export default function ChatPage() {
if (knowledgeSelectionThread.current !== threadId) return;
const metadata = record.metadata as Record<string, unknown> | undefined;
const raw = metadata?.llmwiki_knowledge_base_ids;
const assistantRaw = metadata?.assistant_knowledge_base_ids;
const llmwikiIds = Array.isArray(raw)
? raw.map((item) => String(item).trim()).filter(Boolean)
: [];
const assistantIds = Array.isArray(assistantRaw)
? assistantRaw.map((item) => String(item).trim()).filter(Boolean)
: [];
if (Array.isArray(raw)) {
setSelectedKnowledgeBaseIds(
raw.map((item) => String(item).trim()).filter(Boolean),
);
setKnowledgeSelectionReady(true);
setSelectedKnowledgeBaseIds(llmwikiIds);
} else {
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
if (Array.isArray(assistantRaw)) {
setSelectedAssistantKnowledgeBaseIds(assistantIds);
} else {
setSelectedAssistantKnowledgeBaseIds([]);
}
setKnowledgeSelectionReady(Array.isArray(raw) || Array.isArray(assistantRaw));
})
.catch(() => {
if (knowledgeSelectionThread.current === threadId) {
setSelectedKnowledgeBaseIds([]);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
})
@@ -249,6 +270,7 @@ export default function ChatPage() {
if (!known.some((a) => a.id === selectedAgentId)) {
setSelectedAgentId(null);
setSelectedKnowledgeBaseIds([]);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
@@ -259,7 +281,10 @@ export default function ChatPage() {
threadId: isNewThread ? undefined : threadId,
context: streamContext,
threadMetadata: knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: undefined,
isMock,
onStart: (createdThreadId) => {
@@ -434,7 +459,10 @@ export default function ChatPage() {
metadata: {
agent_id: selectedAgentId,
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: {}),
},
});
@@ -445,7 +473,10 @@ export default function ChatPage() {
threadId: created.thread_id,
text,
...(knowledgeSelectionReady
? { knowledgeBaseIds: selectedKnowledgeBaseIds }
? {
knowledgeBaseIds: selectedKnowledgeBaseIds,
assistantKnowledgeBaseIds: selectedAssistantKnowledgeBaseIds,
}
: {}),
}),
);
@@ -505,6 +536,7 @@ export default function ChatPage() {
hideAdminPrompt,
preferencesLoading,
selectedAgentId,
selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds,
knowledgeSelectionReady,
agentHandoffPending,
@@ -608,6 +640,7 @@ export default function ChatPage() {
const handleAgentChange = useCallback((agentId: string | null) => {
setSelectedAgentId(agentId);
setSelectedKnowledgeBaseIds([]);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
saveLastAgentId(agentId);
}, []);
@@ -842,7 +875,9 @@ export default function ChatPage() {
source={selectedAgentId ? "agent" : "all"}
agentId={selectedAgentId}
value={selectedKnowledgeBaseIds}
assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection}
autoSelectAll={isLandingState && !!selectedAgentId}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
@@ -3,7 +3,11 @@ import { useState } from "react";
import { Navigate, useParams, useSearchParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { KnowledgeProductNav, useKnowledgeBaseProductVersion } from "@/components/workspace/knowledge-product-nav";
import {
KNOWLEDGE_NORMAL_VIEW_VALUE,
KnowledgeProductNav,
useKnowledgeBaseProductVersion,
} from "@/components/workspace/knowledge-product-nav";
import {
Dialog,
DialogContent,
@@ -73,6 +77,7 @@ export default function LlmWikiKnowledgePage({
const runtime = useLlmWikiRuntime();
const productVersion = useKnowledgeBaseProductVersion();
const wikiSlug = searchParams.get("wikiSlug")?.trim() || "";
const explicitNormalView = searchParams.get("view") === KNOWLEDGE_NORMAL_VIEW_VALUE;
if (mode === "detail" && noteId && wikiSlug) {
return <Navigate to={buildWeKnoraWikiPagePath(noteId, wikiSlug)} replace />;
@@ -82,15 +87,18 @@ export default function LlmWikiKnowledgePage({
return <RuntimeLoading />;
}
if (mode === "list" && productVersion.data === "v2") {
if (mode === "list" && productVersion.data === "v2" && !explicitNormalView) {
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
}
if (runtime.isError || runtime.data?.provider !== "weknora") {
if (mode === "list" || mode === "detail") {
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
}
if (mode === "graph") {
return <KnowledgeGraphPage />;
}
return mode === "list" ? <div className="flex size-full min-h-0 flex-col"><KnowledgeProductNav /><div className="min-h-0 flex-1"><LegacyKnowledgePage /></div></div> : <LegacyKnowledgePage />;
return <LegacyKnowledgePage />;
}
if (mode === "graph") {
+7 -1
View File
@@ -450,7 +450,13 @@ export default function WorkspaceRoutes({
<Route
path="library"
element={
<WorkspaceLayout {...layoutProps} workspaceSidebarHidden>
<WorkspaceLayout
{...layoutProps}
workspaceSidebarHidden={
layoutProps.workspaceSidebarHidden ||
!layoutProps.pageSidebarHidden
}
>
<ArtifactLibraryPage />
</WorkspaceLayout>
}
@@ -8,13 +8,21 @@ const BASE = () => `${getBackendBaseURL()}/api/assistant-knowledge`;
export interface AssistantKnowledgeBase {
id: string;
name: string;
description?: string;
base_kind?: "global" | "custom";
is_global?: boolean;
status: string;
source_count?: number;
page_count?: number;
entity_count?: number;
relation_count?: number;
created_at: string;
updated_at: string;
}
export interface AssistantWikiPage {
id: string;
base_id: string;
slug: string;
title: string;
page_type: string;
@@ -37,6 +45,7 @@ export interface AssistantWikiRevision {
export interface AssistantKnowledgeSource {
id: string;
base_id: string;
source_type: "weknora" | "skill" | "local_file";
source_name: string;
source_key: string;
@@ -46,12 +55,34 @@ export interface AssistantKnowledgeSource {
export interface AssistantImportJob {
id: string;
base_id: string;
source_id: string;
mirrored_global_base_id?: string;
mirrored_global_job_id?: string;
package_download_url?: string;
trigger: string;
status: string;
phase: string;
counts: Record<string, number>;
last_error_message?: string | null;
created_at: string;
completed_at?: string | null;
}
export interface LlmWikiExportPackageJob {
id: string;
mapping_id: string;
status: string;
phase: string;
counts: Record<string, number>;
package_download_url?: string;
status_url?: string;
error?: string | null;
created_at?: string | null;
updated_at?: string | null;
completed_at?: string | null;
file_exists?: boolean;
file_size_bytes?: number;
}
async function readJson<T>(response: Response): Promise<T> {
@@ -65,12 +96,41 @@ async function readJson<T>(response: Response): Promise<T> {
return (await response.json()) as T;
}
function normalizeImportJob(job: AssistantImportJob): AssistantImportJob {
const url = normalizeBackendUrl(job.package_download_url);
return url ? { ...job, package_download_url: url } : job;
}
function normalizeBackendUrl(url: string | undefined | null): string | undefined {
if (!url || /^https?:\/\//i.test(url)) return url || undefined;
return `${getBackendBaseURL()}${url.startsWith("/") ? url : `/${url}`}`;
}
function normalizeExportPackageJob(job: LlmWikiExportPackageJob): LlmWikiExportPackageJob {
return {
...job,
package_download_url: normalizeBackendUrl(job.package_download_url),
status_url: normalizeBackendUrl(job.status_url),
};
}
export async function getAssistantKnowledgeBase() {
const data = await readJson<{ base: AssistantKnowledgeBase | null }>(await apiFetch(`${BASE()}/base`));
return data.base;
}
export async function initializeAssistantKnowledgeBase(name = "知识梳理") {
export async function getAssistantKnowledgeBaseById(baseId: string) {
const data = await readJson<{ base: AssistantKnowledgeBase }>(
await apiFetch(`${BASE()}/bases/${encodeURIComponent(baseId)}`),
);
return data.base;
}
export async function listAssistantKnowledgeBases() {
return (await readJson<{ bases: AssistantKnowledgeBase[] }>(await apiFetch(`${BASE()}/bases`))).bases;
}
export async function initializeAssistantKnowledgeBase(name = "知识梳理总库") {
const data = await readJson<{ base: AssistantKnowledgeBase }>(
await apiFetch(`${BASE()}/base/initialize`, {
method: "POST",
@@ -81,58 +141,124 @@ export async function initializeAssistantKnowledgeBase(name = "知识梳理") {
return data.base;
}
export async function listAssistantWikiPages(q = "") {
export async function createAssistantKnowledgeBase(input: { name: string; description?: string }) {
const data = await readJson<{ base: AssistantKnowledgeBase }>(
await apiFetch(`${BASE()}/bases`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
);
return data.base;
}
function withBaseParam(baseId?: string | null) {
const params = new URLSearchParams();
if (baseId) params.set("base_id", baseId);
return params;
}
export async function listAssistantWikiPages(q = "", baseId?: string | null) {
const params = withBaseParam(baseId);
if (q.trim()) params.set("q", q.trim());
return (await readJson<{ pages: AssistantWikiPage[] }>(await apiFetch(`${BASE()}/wiki-pages?${params}`))).pages;
}
export async function listAssistantKnowledgeSources() {
return (await readJson<{ sources: AssistantKnowledgeSource[] }>(await apiFetch(`${BASE()}/sources`))).sources;
export async function listAssistantKnowledgeSources(baseId?: string | null) {
const params = withBaseParam(baseId);
return (await readJson<{ sources: AssistantKnowledgeSource[] }>(await apiFetch(`${BASE()}/sources?${params}`))).sources;
}
export async function listAssistantImportJobs() {
return (await readJson<{ jobs: AssistantImportJob[] }>(await apiFetch(`${BASE()}/import-jobs`))).jobs;
export async function listAssistantImportJobs(baseId?: string | null) {
const params = withBaseParam(baseId);
return (
await readJson<{ jobs: AssistantImportJob[] }>(await apiFetch(`${BASE()}/import-jobs?${params}`))
).jobs.map(normalizeImportJob);
}
export async function listAssistantWikiRevisions(slug: string) {
export async function listAssistantWikiRevisions(slug: string, baseId?: string | null) {
const params = withBaseParam(baseId);
return (await readJson<{ revisions: AssistantWikiRevision[] }>(
await apiFetch(`${BASE()}/wiki-pages/${encodeURIComponent(slug)}/revisions`),
await apiFetch(`${BASE()}/wiki-pages/${encodeURIComponent(slug)}/revisions?${params}`),
)).revisions;
}
export async function rollbackAssistantWikiRevision(slug: string, revisionId: string) {
export async function rollbackAssistantWikiRevision(slug: string, revisionId: string, baseId?: string | null) {
const params = withBaseParam(baseId);
return readJson<AssistantWikiRevision>(
await apiFetch(
`${BASE()}/wiki-pages/${encodeURIComponent(slug)}/revisions/${encodeURIComponent(revisionId)}/rollback`,
`${BASE()}/wiki-pages/${encodeURIComponent(slug)}/revisions/${encodeURIComponent(revisionId)}/rollback?${params}`,
{ method: "POST" },
),
);
}
export async function uploadAssistantKnowledgeFiles(files: File[]) {
const form = new FormData();
files.forEach((file) => form.append("files", file));
return readJson<{ batch_id: string; jobs: AssistantImportJob[] }>(
await apiFetch(`${BASE()}/imports/files`, { method: "POST", body: form }),
export async function exportLlmWikiToAssistant(mappingId: string, assistantBaseId?: string | null) {
return normalizeImportJob(await readJson<AssistantImportJob>(
await apiFetch(
`${getBackendBaseURL()}/api/llmwiki/knowledge-bases/${encodeURIComponent(mappingId)}/export-to-assistant`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assistant_base_id: assistantBaseId || null }),
},
),
));
}
export async function createLlmWikiExportPackage(mappingId: string) {
return normalizeExportPackageJob(
await readJson<LlmWikiExportPackageJob>(
await apiFetch(
`${getBackendBaseURL()}/api/llmwiki/knowledge-bases/${encodeURIComponent(mappingId)}/export-package`,
{ method: "POST" },
),
),
);
}
export async function exportLlmWikiToAssistant(mappingId: string) {
return readJson<AssistantImportJob>(
await apiFetch(
`${getBackendBaseURL()}/api/llmwiki/knowledge-bases/${encodeURIComponent(mappingId)}/export-to-assistant`,
{ method: "POST" },
export async function listLlmWikiExportPackages(mappingId: string) {
return (
await readJson<{ jobs: LlmWikiExportPackageJob[] }>(
await apiFetch(
`${getBackendBaseURL()}/api/llmwiki/knowledge-bases/${encodeURIComponent(mappingId)}/export-package`,
),
)
).jobs.map(normalizeExportPackageJob);
}
export async function getLlmWikiExportPackageStatus(mappingId: string, jobId: string) {
return normalizeExportPackageJob(
await readJson<LlmWikiExportPackageJob>(
await apiFetch(
`${getBackendBaseURL()}/api/llmwiki/knowledge-bases/${encodeURIComponent(mappingId)}/export-package/${encodeURIComponent(jobId)}`,
),
),
);
}
export async function reimportAssistantKnowledgeSource(sourceId: string) {
return readJson<AssistantImportJob>(
return normalizeImportJob(await readJson<AssistantImportJob>(
await apiFetch(`${BASE()}/sources/${encodeURIComponent(sourceId)}/reimport`, {
method: "POST",
}),
);
));
}
export function getAssistantImportPackageUrl(jobId: string) {
return `${BASE()}/import-jobs/${encodeURIComponent(jobId)}/package`;
}
export async function uploadAssistantWeKnoraPackage(input: { file: File; assistantBaseId?: string | null }) {
const body = new FormData();
body.set("file", input.file);
if (input.assistantBaseId) body.set("assistant_base_id", input.assistantBaseId);
return normalizeImportJob(await readJson<AssistantImportJob>(
await apiFetch(`${BASE()}/imports/weknora-package`, {
method: "POST",
body,
}),
));
}
export async function deleteAssistantKnowledgeSource(sourceId: string) {
@@ -144,20 +270,40 @@ export async function deleteAssistantKnowledgeSource(sourceId: string) {
const KEY = "assistant-knowledge";
export function useAssistantKnowledgeBase() {
return useQuery({ queryKey: [KEY, "base"], queryFn: getAssistantKnowledgeBase });
export function useAssistantKnowledgeBase(baseId?: string | null) {
return useQuery({
queryKey: [KEY, "base", baseId ?? "global"],
queryFn: () => (baseId ? getAssistantKnowledgeBaseById(baseId) : getAssistantKnowledgeBase()),
});
}
export function useAssistantWikiPages(query = "") {
return useQuery({ queryKey: [KEY, "pages", query], queryFn: () => listAssistantWikiPages(query) });
export function useAssistantKnowledgeBases() {
return useQuery({ queryKey: [KEY, "bases"], queryFn: listAssistantKnowledgeBases });
}
export function useAssistantKnowledgeSources() {
return useQuery({ queryKey: [KEY, "sources"], queryFn: listAssistantKnowledgeSources });
export function useAssistantWikiPages(query = "", baseId?: string | null) {
return useQuery({
queryKey: [KEY, "pages", baseId ?? "global", query],
queryFn: () => listAssistantWikiPages(query, baseId),
});
}
export function useAssistantImportJobs() {
return useQuery({ queryKey: [KEY, "jobs"], queryFn: listAssistantImportJobs });
export function useAssistantKnowledgeSources(baseId?: string | null) {
return useQuery({
queryKey: [KEY, "sources", baseId ?? "global"],
queryFn: () => listAssistantKnowledgeSources(baseId),
});
}
export function useAssistantImportJobs(baseId?: string | null) {
return useQuery({
queryKey: [KEY, "jobs", baseId ?? "global"],
queryFn: () => listAssistantImportJobs(baseId),
refetchInterval: (query) =>
(query.state.data ?? []).some((job) => ["queued", "running"].includes(job.status))
? 3000
: false,
});
}
export function useAssistantKnowledgeActions() {
@@ -165,12 +311,13 @@ export function useAssistantKnowledgeActions() {
const invalidate = () => client.invalidateQueries({ queryKey: [KEY] });
return {
initialize: useMutation({ mutationFn: initializeAssistantKnowledgeBase, onSuccess: invalidate }),
upload: useMutation({ mutationFn: uploadAssistantKnowledgeFiles, onSuccess: invalidate }),
createBase: useMutation({ mutationFn: createAssistantKnowledgeBase, onSuccess: invalidate }),
uploadWeKnoraPackage: useMutation({ mutationFn: uploadAssistantWeKnoraPackage, onSuccess: invalidate }),
reimportSource: useMutation({ mutationFn: reimportAssistantKnowledgeSource, onSuccess: invalidate }),
deleteSource: useMutation({ mutationFn: deleteAssistantKnowledgeSource, onSuccess: invalidate }),
rollback: useMutation({
mutationFn: ({ slug, revisionId }: { slug: string; revisionId: string }) =>
rollbackAssistantWikiRevision(slug, revisionId),
mutationFn: ({ slug, revisionId, baseId }: { slug: string; revisionId: string; baseId?: string | null }) =>
rollbackAssistantWikiRevision(slug, revisionId, baseId),
onSuccess: invalidate,
}),
};
@@ -40,6 +40,9 @@ export interface LlmWikiKnowledgeBase {
share_count?: number;
is_processing?: boolean;
is_temporary?: boolean;
wiki_index_enabled?: boolean;
external_search_enabled?: boolean;
external_search_updated_at?: string | null;
embedding_model_id?: string | null;
vector_store_id?: string | null;
vector_store_name?: string | null;
@@ -1,15 +1,11 @@
import { AlertCircle, Database, Edit3, FileText, Loader2, RefreshCw, Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
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 { 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";
@@ -24,9 +20,20 @@ import {
useLlmWikiWikiPage,
useLlmWikiRuntime,
} from "@/strategy-components/api/llmwiki";
import { exportLlmWikiToAssistant } from "@/strategy-components/api/assistant-knowledge";
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";
@@ -94,6 +101,25 @@ function buildFrameUrl(framePath: string) {
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();
}
export function WeKnoraKnowledgeDetailPage() {
const {
knowledge_base_id: strategyKnowledgeBaseId,
@@ -106,16 +132,40 @@ export function WeKnoraKnowledgeDetailPage() {
const [frameSrc, setFrameSrc] = useState("");
const [frameVersion, setFrameVersion] = useState(0);
const [frameError, setFrameError] = useState<string | null>(null);
const [assistantImporting, setAssistantImporting] = useState(false);
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 detailsReady = !!details.data;
const requestedTab = searchParams.get("tab");
const detailsWikiEnabled =
details.data?.type === "wiki" ||
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);
const preferredTab =
requestedTab === "documents" || requestedTab === "wiki" || requestedTab === "graph"
? requestedTab
: details.data?.indexing_strategy?.wiki_enabled
: detailsWikiEnabled
? "wiki"
: "documents";
const sourceChunkId = searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
@@ -182,6 +232,22 @@ 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 />;
}
@@ -235,8 +301,25 @@ export function WeKnoraKnowledgeDetailPage() {
const canWrite = details.data.can_write && !details.data.is_conversation_deposit;
const canEditCurrentWikiArticle =
canWrite && !!details.data.indexing_strategy?.wiki_enabled && !!wikiSlug;
const canManageWiki = canWrite && !!details.data.indexing_strategy?.wiki_enabled;
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">
@@ -258,21 +341,15 @@ export function WeKnoraKnowledgeDetailPage() {
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{getStoredAuth()?.system_role === "admin" && (
{isAdmin && (
<Button
size="sm"
variant="outline"
disabled={assistantImporting}
onClick={() => {
setAssistantImporting(true);
void exportLlmWikiToAssistant(knowledgeBaseId)
.then(() => toast.success("已导入到助手知识库“知识梳理”"))
.catch((error: unknown) => toast.error(error instanceof Error ? error.message : String(error)))
.finally(() => setAssistantImporting(false));
}}
disabled={exportPackageLoading}
onClick={createVectorizedPackage}
>
{assistantImporting ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Database className="mr-2 size-4" />}
导入到助手知识库
{exportPackageLoading ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Download className="mr-2 size-4" />}
生成向量化数据包
</Button>
)}
<Button
@@ -286,6 +363,13 @@ export function WeKnoraKnowledgeDetailPage() {
</div>
</div>
)}
{canManageWiki && isAdmin && (
<ExportPackageJobsPanel
jobs={exportJobs}
loading={exportJobsLoading}
onRefresh={() => void refreshExportJobs()}
/>
)}
{frameSrc && (
<iframe
key={`${frameSrc}:${frameVersion}`}
@@ -313,6 +397,84 @@ 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,