From 6b724bd00d31952639b8e25296f7e47a0fb739ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Wed, 2 Sep 2026 17:55:17 +0800 Subject: [PATCH] feat: improve assistant knowledge import and database compatibility --- .gitignore | 2 + .../docs/技能归纳至WeKnora知识库-实现设计.md | 94 +- .../workspace/knowledge-product-nav.tsx | 4 +- .../workspace/llmwiki-knowledge-selector.tsx | 342 +++++-- .../workspace/skills/SkillKnowledgePanel.tsx | 177 ++-- .../workspace/workspace-nav-chat-list.tsx | 9 +- .../workspace/workspace-nav-menu.tsx | 8 + frontend-web/src/core/artifacts/download.ts | 29 +- frontend-web/src/core/artifacts/library.ts | 11 + frontend-web/src/core/threads/hooks.ts | 24 +- frontend-web/src/core/threads/types.ts | 2 + frontend-web/src/pages/AdminSkillsPage.tsx | 18 +- frontend-web/src/pages/AgentChatPage.tsx | 83 +- .../src/pages/ArtifactLibraryPage.tsx | 212 +++- .../src/pages/AssistantKnowledgePage.tsx | 444 ++++++++- frontend-web/src/pages/ChatPage.tsx | 53 +- .../src/pages/LlmWikiKnowledgePage.tsx | 14 +- frontend-web/src/pages/WorkspaceRoutes.tsx | 8 +- .../api/assistant-knowledge.ts | 213 +++- .../src/strategy-components/api/llmwiki.ts | 3 + .../llmwiki/WeKnoraKnowledgeDetailPage.tsx | 210 +++- offline-backend-20260512/backend/CLAUDE.md | 41 +- offline-backend-20260512/backend/Dockerfile | 4 +- offline-backend-20260512/backend/README.md | 70 +- .../backend/app/gateway/deps.py | 12 +- .../backend/app/gateway/llmwiki_rag.py | 195 +++- .../app/gateway/routers/admin_users.py | 4 +- .../backend/app/gateway/routers/artifacts.py | 684 ++++++------- .../gateway/routers/assistant_knowledge.py | 916 +++++++++++++++--- .../app/gateway/routers/skill_knowledge.py | 24 +- .../backend/app/gateway/services.py | 7 +- offline-backend-20260512/backend/config.yaml | 15 +- .../assistant_knowledge/package_io.py | 109 +++ .../deerflow/config/database_config.py | 89 +- .../harness/deerflow/config/llmwiki_config.py | 2 +- .../deerflow/integrations/weknora/client.py | 2 +- .../weknora/local_index/__init__.py | 38 +- .../persistence/assistant_knowledge/model.py | 37 +- .../persistence/assistant_knowledge/store.py | 837 +++++++++++++++- .../harness/deerflow/persistence/engine.py | 44 +- .../persistence/llmwiki_index/base.py | 18 + .../deerflow/persistence/llmwiki_index/sql.py | 36 + ...20260902_01_assistant_knowledge_vectors.py | 25 + .../deerflow/persistence/models/__init__.py | 1 + .../harness/deerflow/persistence/types.py | 14 +- .../backend/packages/harness/pyproject.toml | 8 +- .../backend/pyproject.toml | 1 + .../backend/tests/test_artifact_library.py | 40 +- .../test_assistant_knowledge_repository.py | 97 ++ .../backend/tests/test_gauss_database.py | 81 ++ .../test_llmwiki_local_index_repository.py | 3 + 51 files changed, 4393 insertions(+), 1021 deletions(-) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/assistant_knowledge/package_io.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260902_01_assistant_knowledge_vectors.py create mode 100644 offline-backend-20260512/backend/tests/test_gauss_database.py diff --git a/.gitignore b/.gitignore index bd77aabb..6561115f 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,8 @@ skills/ hermes-agent-main/ .gitignore offline-backend-20260512/backend/tests/* +# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned. +!offline-backend-20260512/backend/tests/test_gauss_database.py !offline-backend-20260512/backend/tests/test_llmwiki_weknora.py !offline-backend-20260512/backend/tests/test_health_auth_bypass.py # Keep formal Word OOXML and embedded-font regression coverage versioned. diff --git a/frontend-web/docs/技能归纳至WeKnora知识库-实现设计.md b/frontend-web/docs/技能归纳至WeKnora知识库-实现设计.md index a8a5b80e..c2f22081 100644 --- a/frontend-web/docs/技能归纳至WeKnora知识库-实现设计.md +++ b/frontend-web/docs/技能归纳至WeKnora知识库-实现设计.md @@ -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 | 写自研实体/关系表;总库做跨库实体对齐 | 只读目标说明: diff --git a/frontend-web/src/components/workspace/knowledge-product-nav.tsx b/frontend-web/src/components/workspace/knowledge-product-nav.tsx index b6417d86..96af3aca 100644 --- a/frontend-web/src/components/workspace/knowledge-product-nav.tsx +++ b/frontend-web/src/components/workspace/knowledge-product-nav.tsx @@ -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 (
- - ); - })} -
- )) +
+ {group.key === "personal" ? : }{group.label} +
+ )} + {group.rows.map((item) => { + const rowContent = ( + <> + +
+
{item.name}
+
+ {item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识 +
+
+ {item.publication_status === "published" && ( + 公共 + )} + + ); + return variant === "popover" ? ( + toggle(item.id)} + onSelect={(event) => event.preventDefault()} + className={cn( + "my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7", + selected.has(item.id) && "bg-primary/8 focus:bg-primary/12", + )} + > + {rowContent} + + ) : ( + + ); + })} + + ))} + {filteredAssistant.length > 0 && ( +
+ {(groups.length > 0) && ( + variant === "popover" + ? + :
+ )} + {variant === "popover" ? ( + + 助手知识库 + + ) : ( +
+ 助手知识库 +
+ )} + {filteredAssistant.map((item) => { + const rowContent = ( + <> + +
+
{item.name}
+
+ Wiki · {item.page_count ?? 0} 页 · {item.entity_count ?? 0} 实体 +
+
+ + {item.is_global ? "总库" : "助手"} + + + ); + return variant === "popover" ? ( + toggleAssistant(item.id)} + onSelect={(event) => event.preventDefault()} + className={cn( + "my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7", + selectedAssistant.has(item.id) && "bg-primary/8 focus:bg-primary/12", + )} + > + {rowContent} + + ) : ( + + ); + })} +
+ )} + )}
- {readOnly ? `本对话已选 ${value.length} 个` : `已选择 ${value.length} 个`} + {readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`} {readOnly ? ( 对话开始后不可更改 ) : (
- -
@@ -232,7 +363,9 @@ function SelectionContent({ export function LlmWikiKnowledgeSelector({ value, + assistantValue = [], onChange, + onAssistantChange = () => undefined, source = "all", agentId, autoSelectAll = false, @@ -243,7 +376,9 @@ export function LlmWikiKnowledgeSelector({ variant = "popover", }: { value: string[]; + assistantValue?: string[]; onChange: (value: string[]) => void; + onAssistantChange?: (value: string[]) => void; source?: SelectorSource; agentId?: string | null; autoSelectAll?: boolean; @@ -257,28 +392,39 @@ export function LlmWikiKnowledgeSelector({ const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const runtime = useLlmWikiRuntime(); + const weknoraEnabled = runtime.data?.provider === "weknora"; const all = useLlmWikiKnowledgeBases( "all", - runtime.data?.provider === "weknora", + weknoraEnabled, ); const agent = useAgentLlmWikiKnowledgeBases( agentId, - runtime.data?.provider === "weknora" && source === "agent" && !!agentId, + weknoraEnabled && source === "agent" && !!agentId, ); - const rows = all.data ?? []; + const assistantBases = useAssistantKnowledgeBases(); + const rows = weknoraEnabled ? (all.data ?? []) : []; + const assistantRows = assistantBases.data ?? []; const defaultIds = useMemo(() => { if (source !== "agent") { return rows.map((item) => item.id); } return (agent.data ?? []).map((item) => item.id); }, [agent.data, rows, source]); - const loading = all.isLoading || (source === "agent" && !!agentId && agent.isLoading); - const error = all.error ?? (source === "agent" ? agent.error : null); + const loading = runtime.isLoading || (weknoraEnabled && all.isLoading) || assistantBases.isLoading || (source === "agent" && !!agentId && agent.isLoading); + const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? assistantBases.error; const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]); + const assistantAvailableIds = useMemo( + () => new Set(assistantRows.map((item) => item.id)), + [assistantRows], + ); const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`; const initialized = useRef(null); useEffect(() => { + if (!runtime.isLoading && !weknoraEnabled) { + if (value.length > 0) onChange([]); + return; + } if (!all.data || (source === "agent" && agentId && !agent.data) || initialized.current === initializationKey) return; initialized.current = initializationKey; const valid = value.filter((id) => availableIds.has(id)); @@ -288,19 +434,30 @@ export function LlmWikiKnowledgeSelector({ if (next.length !== value.length || next.some((id, index) => id !== value[index])) { onChange(next); } - }, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, source, value]); + }, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, runtime.isLoading, source, value, weknoraEnabled]); + + useEffect(() => { + if (!assistantBases.data) return; + const valid = assistantValue.filter((id) => assistantAvailableIds.has(id)); + if (valid.length !== assistantValue.length || valid.some((id, index) => id !== assistantValue[index])) { + onAssistantChange(valid); + } + }, [assistantAvailableIds, assistantBases.data, assistantValue, onAssistantChange]); useEffect(() => { if (!open) setSearch(""); }, [open]); - if (runtime.isError || runtime.data?.provider !== "weknora") return null; + if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null; const content = (
- {value.length} 个 + {value.length + assistantValue.length} 个 {content} @@ -333,10 +490,15 @@ export function LlmWikiKnowledgeSelector({ const selectedNames = value .map((id) => rows.find((item) => item.id === id)?.name) .filter((name): name is string => !!name); - const selectedLabel = selectedNames.length > 0 - ? selectedNames.join("、") - : value.length > 0 - ? `已选 ${value.length} 个知识空间` + const selectedAssistantNames = assistantValue + .map((id) => assistantRows.find((item) => item.id === id)?.name) + .filter((name): name is string => !!name); + const allSelectedNames = [...selectedNames, ...selectedAssistantNames]; + const selectedCount = value.length + assistantValue.length; + const selectedLabel = allSelectedNames.length > 0 + ? allSelectedNames.join("、") + : selectedCount > 0 + ? `已选 ${selectedCount} 个知识空间` : label; return ( @@ -348,20 +510,20 @@ export function LlmWikiKnowledgeSelector({ size="sm" className={cn( "h-8 max-w-52 gap-1.5 rounded-full border-0 bg-transparent px-2.5 text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground", - value.length > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary", + selectedCount > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary", className, )} disabled={disabled || loading} - title={value.length > 0 ? selectedLabel : undefined} + title={selectedCount > 0 ? selectedLabel : undefined} > {loading ? : } {selectedLabel} - {value.length > 0 && ( + {selectedCount > 0 && ( - {value.length} + {selectedCount} )} - {value.length > 0 && value.length === rows.length && } + {selectedCount > 0 && selectedCount === rows.length + assistantRows.length && } diff --git a/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx b/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx index 77fe43d7..c93a520c 100644 --- a/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx +++ b/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx @@ -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 = { @@ -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([]); - const [selectedTargetKeys, setSelectedTargetKeys] = useState([]); - const [reviewMode, setReviewMode] = useState<"required" | "auto_high_confidence" | "off">("auto_high_confidence"); + const [skillQuery, setSkillQuery] = useState(""); + const [selectedTargetKey, setSelectedTargetKey] = useState(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(() => { - 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() {
-

批量归纳

-

按“技能 × 目标知识库”生成独立后台任务;代码与脚本不会被读取。

+

技能到知识库管理

+

一次选择多个技能归纳到一个 WeKnora Wiki 知识库;代码与脚本不会被读取。

1. 选择技能({selectedSkills.length}) - + +
+
+ + setSkillQuery(event.target.value)} + placeholder="按技能中文名、英文名、描述、分类或标签搜索" + className="pl-8" + />
- {isLoading ? : liveSkills.map((skill) => ( + {isLoading ? : filteredSkills.map((skill) => ( ))} + {!isLoading && !filteredSkills.length && ( +

没有匹配的技能,请换个关键词。

+ )}
+

+ 当前显示 {filteredSkills.length}/{liveSkills.length} 个技能。 +

- 2. 选择目标({selectedTargetKeys.length}) - {!assistantBase.data && ( - - )} + 2. 选择目标知识库({selectedTargetKey ? 1 : 0})
{targets.map((target) => { const key = `${target.target_type}:${target.target_id}`; return ( ); })} - {!targets.length &&

未配置普通知识库;可先创建全局“知识梳理”助手库。

} + {(runtime.isLoading || knowledgeBases.isLoading) && } + {!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && ( +

+ 读取 WeKnora 普通知识库失败: + {knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)} +

+ )} + {!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && ( +

+ {runtime.data?.provider !== "weknora" + ? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。" + : "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"} +

+ )}
@@ -156,14 +209,13 @@ export function SkillKnowledgePanel() { 高置信自动通过 - 全部逐条审核 不阻塞写入
@@ -199,26 +251,9 @@ export function SkillKnowledgePanel() {
- {(reviews.data?.length ?? 0) > 0 && ( -
-

待审核({reviews.data!.length})

- {reviews.data!.map((review) =>
{review.object_type}{String(review.original.name || review.original.predicate || review.object_key)}
)} -
- )}
-
- 自动同步 - {autoSettings.data && <> - - - - setScheduleCron(event.target.value)} aria-label="自动同步 cron" /> - setDebounceSeconds(Number(event.target.value))} aria-label="防抖秒数" /> - - } -
当前有效绑定:{bindings.data?.length ?? 0};同名同类型实体按来源贡献合并。
-
{bindings.data?.map((binding) =>
{binding.skill_name}→ {binding.target_name}{binding.status}{binding.remote_business_key}
)}
+
{bindings.data?.map((binding) =>
{binding.skill_name}→ {binding.target_name}{binding.status}{binding.remote_business_key}
)}
diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index d3a26b04..f9065b45 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -14,7 +14,7 @@ import { SidebarMenuItem, } from "@/components/ui/sidebar"; import { useI18n } from "@/core/i18n/hooks"; -import { BookOpen, BotIcon, BrainIcon, FileSearch, 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: "智能体", diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 05e15990..433753da 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -229,6 +229,14 @@ export function WorkspaceNavMenu() { 技能管理 )} + {isAdmin && ( + navigate("/page/workspace/admin/skills?tab=knowledge")} + > + + 技能到知识库管理 + + )} {isAdmin && ( navigate("/page/workspace/admin/models")} diff --git a/frontend-web/src/core/artifacts/download.ts b/frontend-web/src/core/artifacts/download.ts index e3f6b6b8..a051628a 100644 --- a/frontend-web/src/core/artifacts/download.ts +++ b/frontend-web/src/core/artifacts/download.ts @@ -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 { 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 { + 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, diff --git a/frontend-web/src/core/artifacts/library.ts b/frontend-web/src/core/artifacts/library.ts index a088791a..ade3d60c 100644 --- a/frontend-web/src/core/artifacts/library.ts +++ b/frontend-web/src/core/artifacts/library.ts @@ -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; 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 { 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; 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, diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index f83f0cdd..51d2c633 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -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); } diff --git a/frontend-web/src/core/threads/types.ts b/frontend-web/src/core/threads/types.ts index 9639a63e..c1b606b7 100644 --- a/frontend-web/src/core/threads/types.ts +++ b/frontend-web/src/core/threads/types.ts @@ -27,6 +27,8 @@ export interface AgentThreadContext extends Record { 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; diff --git a/frontend-web/src/pages/AdminSkillsPage.tsx b/frontend-web/src/pages/AdminSkillsPage.tsx index 4d3e0407..6ac3ffc4 100644 --- a/frontend-web/src/pages/AdminSkillsPage.tsx +++ b/frontend-web/src/pages/AdminSkillsPage.tsx @@ -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() {
@@ -77,7 +91,7 @@ export default function AdminSkillsPage() { - 技能知识归纳 + 技能到知识库管理
diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index f57b6b52..30326e06 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -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( () => pendingKnowledgeSelection.current ?? [], ); + const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState( + () => pendingAssistantKnowledgeSelection.current ?? [], + ); const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState( - () => pendingKnowledgeSelection.current !== null, + () => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null, ); const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false); const knowledgeSelectionThread = useRef(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 | 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="知识空间" diff --git a/frontend-web/src/pages/ArtifactLibraryPage.tsx b/frontend-web/src/pages/ArtifactLibraryPage.tsx index 576003e6..2424fe5d 100644 --- a/frontend-web/src/pages/ArtifactLibraryPage.tsx +++ b/frontend-web/src/pages/ArtifactLibraryPage.tsx @@ -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 ( @@ -106,7 +149,8 @@ function FileRow({ {displayName} - {getFileExtensionDisplayName(item.name)} · 来自「{sourceTitle}」 + {getFileExtensionDisplayName(item.name)} · {sourceName} · 来自「 + {sourceTitle}」 {getFileExtensionDisplayName(item.name)} 文件 @@ -115,13 +159,24 @@ function FileRow({ - - {sourceTitle} - +
+ + + + + + {sourceName} + + + {item.sourceType === "agent" ? "智能体 · " : ""} + {sourceTitle} + + +
{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" ? ( ) : ( )} + {isMarkdownArtifact(item.name) && ( + + )} + ))} + +
@@ -337,11 +456,11 @@ export default function ArtifactLibraryPage() { - - - - - + + + + @@ -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 ( void handleDownload(target)} + downloading={activeDownload} + onDownload={(target, format) => + void handleDownload(target, format) + } /> ); }) @@ -388,7 +514,7 @@ export default function ArtifactLibraryPage() {

- {debouncedSearch || fileKind !== "all" + {debouncedSearch || fileKind !== "all" || sourceType !== "all" ? "没有找到匹配的文件" : "还没有生成文件"}

diff --git a/frontend-web/src/pages/AssistantKnowledgePage.tsx b/frontend-web/src/pages/AssistantKnowledgePage.tsx index a0009085..444eac15 100644 --- a/frontend-web/src/pages/AssistantKnowledgePage.tsx +++ b/frontend-web/src/pages/AssistantKnowledgePage.tsx @@ -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 ( +
+
+ +

初始化助手知识库

+

+ 未配置 WeKnora 时会默认展示助手知识库。首次使用需要创建“知识梳理总库”,后续从 WeKnora 导入的数据都会沉淀到这里。 +

+ {isAdmin ? ( + + ) : ( +

请联系管理员初始化。

+ )} +
+
+ ); +} + export default function AssistantKnowledgePage() { const isAdmin = getStoredAuth()?.system_role === "admin"; const [query, setQuery] = useState(""); + const [selectedBaseId, setSelectedBaseId] = useState(null); const [selected, setSelected] = useState(null); - const [revisions, setRevisions] = useState([]); - const fileRef = useRef(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(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
; - if (!base.data) { - return

创建“知识梳理”助手知识库

无需配置普通知识库服务。创建后所有用户可查看 Wiki 与搜索,只有管理员可导入和维护。

{isAdmin ? :

请联系管理员初始化。

}
; - } - - 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 ( +
+ +
+ +
+
+ ); + } + + if (!bases.data?.length) { + return ( +
+ + +
+ ); + } + return (
-

{base.data.name}

全局助手知识库 · 普通用户只读

实体对齐与版本管理
{isAdmin && <> void uploadFiles(event.target.files)} />}
- -
Wiki({counts.pages})来源({counts.sources})导入记录({counts.jobs})版本({revisions.length})
setQuery(event.target.value)} placeholder="搜索标题、实体别名和正文" className="pl-8" />
-
{pages.data?.map((page) => )}
{activePage ? <>

{activePage.title}

{activePage.slug}

:

暂无 Wiki 页面。

}
-
{sources.data?.map((source) =>
{source.source_name}{source.source_type}

业务键:{source.source_key}

{isAdmin &&
}
)}
-
{jobs.data?.map((job) =>
{job.status}{job.trigger}{Object.entries(job.counts || {}).map(([key, value]) => `${key} ${value}`).join(" · ")}{new Date(job.created_at).toLocaleString()}
)}
-
{revisions.map((revision) =>
v{revision.revision_no}{revision.change_type}{revision.summary}{new Date(revision.created_at).toLocaleString()}{isAdmin && activePage && revision.id !== activePage.current_revision_id && }
)}{!revisions.length &&

请先在 Wiki 页选择页面并点击“页面版本”。

}
-
+
+ + +
+
+
+

{selectedBase?.name}

+

+ {selectedBase ? baseSubtitle(selectedBase) : "请选择助手知识库"} +

+
+ + + 实体对齐 + + {isAdmin && ( +
+ void uploadPackage(event.target.files?.[0])} + /> + +
+ )} +
+ + +
+ + Wiki({counts.pages}) + 来源({counts.sources}) + 导入记录({counts.jobs}) + +
+ + setQuery(event.target.value)} + placeholder="搜索标题、实体别名和正文" + className="pl-8" + /> +
+
+ + + +
+ {pages.isLoading && } + {pages.data?.map((page) => ( + + ))} + {!pages.isLoading && !pages.data?.length && ( +
+ 暂无 Wiki 页面。请先到普通知识库详情页下载向量化数据包,再在当前助手知识库上传。 +
+ )} +
+
+ +
+ {activePage ? ( + <> +

+ + {activePage.title} +

+

{activePage.slug}

+ + + ) : ( +

暂无 Wiki 页面。

+ )} +
+
+
+ + +
+ {sources.data?.map((source) => ( +
+
+ + {source.source_name} + {source.source_type} +
+

业务键:{source.source_key}

+ {isAdmin && ( +
+ + +
+ )} +
+ ))} + {!sources.data?.length &&

暂无来源。

} +
+
+ + +
+ {jobs.data?.map((job) => ( +
+
+ {job.status} + {job.trigger} + 阶段:{job.phase} + {new Date(job.created_at).toLocaleString()} + {isAdmin && job.package_download_url && ( + + )} +
+
+ {Object.entries(job.counts || {}).map(([key, value]) => `${key} ${value}`).join(" · ") || "暂无统计"} +
+ {job.last_error_message && ( +
+ {job.last_error_message} +
+ )} +
+ ))} + {!jobs.data?.length &&

暂无导入记录。

} +
+
+
+
+
+ + + + + 新建助手知识库 + + 自建助手知识库用于承接从 WeKnora 普通知识库下载的 Wiki 向量包;导入时同一份数据也会沉淀到“知识梳理总库”。 + + +
+
+ + setCreateDraft((prev) => ({ ...prev, name: event.target.value }))} + placeholder="例如:政策资料库" + /> +
+
+ +
文件名来源对话生成时间大小 + 文件名来源生成时间大小 操作