diff --git a/.gitignore b/.gitignore index 407a187f..60964980 100644 --- a/.gitignore +++ b/.gitignore @@ -88,10 +88,15 @@ 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_llmwiki_knowledge_management.py !offline-backend-20260512/backend/tests/test_health_auth_bypass.py # Keep formal Word OOXML and embedded-font regression coverage versioned. !offline-backend-20260512/backend/tests/test_word_export.py +# Keep generated-file library aggregation and filtering regression coverage versioned. +!offline-backend-20260512/backend/tests/test_artifact_library.py # Keep the model configuration regression coverage versioned even though # this deployment repository otherwise ignores newly added backend tests. !offline-backend-20260512/backend/tests/test_model_management.py @@ -171,8 +176,10 @@ docs/backups # Frontend skills components must be tracked (excluded from the broad skills/ ignore above) !frontend-web/src/components/workspace/skills/ -# uv lockfile (not tracked per project preference) -offline-backend-20260512/backend/uv.lock +# uv lockfile (not tracked per project preference) +offline-backend-20260512/backend/uv.lock + +!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py # Local-only scratch directory (not committed) 0622/ diff --git a/browser-extension/deer-web-clipper/background.js b/browser-extension/deer-web-clipper/background.js index 104380f7..686b9660 100644 --- a/browser-extension/deer-web-clipper/background.js +++ b/browser-extension/deer-web-clipper/background.js @@ -5,6 +5,9 @@ const LEGACY_LOCAL_API_BASE_URL = "http://localhost:8001"; const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174"; let pollTimer = null; let isPolling = false; +let startupPromise = null; + +const POLLING_SCHEDULE_SETTING_KEYS = new Set(["taskPollingEnabled", "taskPollIntervalSeconds"]); function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -12,6 +15,14 @@ function delay(ms) { async function getSettings() { const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS); + const discovered = await chrome.storage.local.get({ + discoveredApiBaseUrl: "", + discoveredWebBaseUrl: "", + }); + if (settings.autoDiscoverServiceUrl) { + settings.apiBaseUrl = discovered.discoveredApiBaseUrl || settings.apiBaseUrl; + settings.webBaseUrl = discovered.discoveredWebBaseUrl || settings.webBaseUrl; + } const updates = {}; if (!settings.apiBaseUrl || settings.apiBaseUrl === LEGACY_LOCAL_API_BASE_URL) { updates.apiBaseUrl = DEER_CLIPPER_DEFAULTS.apiBaseUrl; @@ -69,10 +80,7 @@ function apiCandidatesForWebBase(webBase, currentApiBase) { } }; - add(currentApiBase); add(webBase); - add(DEER_CLIPPER_DEFAULTS.apiBaseUrl); - add(LEGACY_LOCAL_API_BASE_URL); try { const url = new URL(normalizeServiceBase(webBase)); @@ -85,6 +93,10 @@ function apiCandidatesForWebBase(webBase, currentApiBase) { // Ignore invalid candidates. } + add(currentApiBase); + add(DEER_CLIPPER_DEFAULTS.apiBaseUrl); + add(LEGACY_LOCAL_API_BASE_URL); + return candidates; } @@ -93,6 +105,12 @@ async function browserContextStatus(apiBaseUrl) { const response = await fetch(`${apiBaseUrl}/api/browser-context/status`, { credentials: "include", }); + // The gateway may enforce authentication globally before this otherwise + // public discovery endpoint is reached. A 401/403 still proves that this + // candidate is the zncm API paired with the open frontend tab. + if (response.status === 401 || response.status === 403) { + return { enabled: true, authenticationRequired: true }; + } if (!response.ok) { return null; } @@ -201,9 +219,12 @@ async function discoverServiceBases(settings, force = false) { settings.webBaseUrl !== next.webBaseUrl || settings.lastServiceSyncSource !== next.lastServiceSyncSource ) { - await chrome.storage.sync.set({ - apiBaseUrl: next.apiBaseUrl, - webBaseUrl: next.webBaseUrl, + // Discovered endpoints are runtime state, not user-authored preferences. + // Keep them in storage.local so Chrome sync write quotas cannot break task + // polling. The options page may still persist explicit user choices. + await chrome.storage.local.set({ + discoveredApiBaseUrl: next.apiBaseUrl, + discoveredWebBaseUrl: next.webBaseUrl, lastServiceSyncAt: next.lastServiceSyncAt, lastServiceSyncSource: next.lastServiceSyncSource, }); @@ -826,19 +847,50 @@ async function pollOnce() { } } -async function startup() { +async function initializePolling() { let settings = await getSettings(); - settings = await discoverServiceBases(settings); - settings = await syncAuthToken(settings); + try { + settings = await discoverServiceBases(settings); + } catch (error) { + // Service discovery persists diagnostic metadata in storage.sync. Chrome can + // temporarily reject those writes after its quota is reached; polling must + // still start with the last known settings. + console.warn("zncm service discovery failed during startup", error); + } + try { + settings = await syncAuthToken(settings); + } catch (error) { + // A failed status/token write must not prevent alarms from being installed. + // pollOnce() will retry token synchronization before claiming each task. + console.warn("zncm token sync failed during startup", error); + } await refreshAlarm(settings); await scheduleNextPoll(1000); } -chrome.runtime.onInstalled.addListener(() => void startup()); -chrome.runtime.onStartup.addListener(() => void startup()); +function startup() { + if (!startupPromise) { + startupPromise = initializePolling().finally(() => { + startupPromise = null; + }); + } + return startupPromise; +} + +async function restartPollingSchedule() { + const settings = await getSettings(); + await refreshAlarm(settings); + await scheduleNextPoll(0); +} + +chrome.runtime.onInstalled.addListener(() => void startup().catch((error) => console.warn("zncm startup failed", error))); +chrome.runtime.onStartup.addListener(() => void startup().catch((error) => console.warn("zncm startup failed", error))); chrome.storage.onChanged.addListener((changes, areaName) => { - if (areaName === "sync") { - void startup(); + if ( + areaName === "sync" && + Object.keys(changes || {}).some((key) => POLLING_SCHEDULE_SETTING_KEYS.has(key)) + ) { + void restartPollingSchedule().catch((error) => console.warn("zncm polling reschedule failed", error)); } }); chrome.alarms.onAlarm.addListener((alarm) => { @@ -902,4 +954,4 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; }); -void startup(); +void startup().catch((error) => console.warn("zncm startup failed", error)); diff --git a/browser-extension/deer-web-clipper/background.test.cjs b/browser-extension/deer-web-clipper/background.test.cjs new file mode 100644 index 00000000..e1462ff8 --- /dev/null +++ b/browser-extension/deer-web-clipper/background.test.cjs @@ -0,0 +1,130 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +const BACKGROUND_PATH = path.join(__dirname, "background.js"); + +function createHarness() { + let storageChangedListener; + let alarmCreateCount = 0; + const settings = { + apiBaseUrl: "http://localhost:8001", + webBaseUrl: "http://localhost:5174", + autoDiscoverServiceUrl: false, + accessToken: "", + tokenSyncEnabled: false, + taskPollingEnabled: true, + workerId: "test-worker", + taskPollIntervalSeconds: 5, + }; + + const chrome = { + alarms: { + clear: async () => {}, + create: async () => { + alarmCreateCount += 1; + }, + onAlarm: { addListener: () => {} }, + }, + cookies: { get: async () => null }, + runtime: { + onInstalled: { addListener: () => {} }, + onMessage: { addListener: () => {} }, + onStartup: { addListener: () => {} }, + }, + storage: { + local: { + get: async (defaults) => ({ ...defaults }), + set: async () => {}, + }, + session: { get: async () => ({ actSessions: {} }), set: async () => {} }, + sync: { + get: async () => ({ ...settings }), + set: async (updates) => Object.assign(settings, updates), + }, + onChanged: { + addListener: (listener) => { + storageChangedListener = listener; + }, + }, + }, + tabs: { query: async () => [] }, + }; + + const context = { + chrome, + console, + fetch: async () => { + throw new Error("offline test"); + }, + importScripts: () => {}, + setTimeout: () => 1, + clearTimeout: () => {}, + URL, + DEER_CLIPPER_DEFAULTS: settings, + deerNormalizeBaseUrl: (value) => String(value || "").replace(/\/+$/, ""), + deerMakeWorkerId: () => "test-worker", + deerAuthHeaders: () => ({}), + deerExtractAuthTokenFromPage: () => ({ token: "" }), + }; + context.globalThis = context; + vm.runInNewContext(fs.readFileSync(BACKGROUND_PATH, "utf8"), context, { + filename: BACKGROUND_PATH, + }); + + return { + emitStorageChange(changes) { + storageChangedListener(changes, "sync"); + }, + getAlarmCreateCount: () => alarmCreateCount, + resetAlarmCreateCount: () => { + alarmCreateCount = 0; + }, + getApiCandidates: (webBase, currentApiBase) => + vm.runInContext(`apiCandidatesForWebBase(${JSON.stringify(webBase)}, ${JSON.stringify(currentApiBase)})`, context), + }; +} + +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +test("self-written sync metadata does not restart browser task polling", async () => { + const harness = createHarness(); + await settle(); + harness.resetAlarmCreateCount(); + + harness.emitStorageChange({ + lastTokenSyncAt: { oldValue: "before", newValue: "after" }, + }); + await settle(); + + assert.equal(harness.getAlarmCreateCount(), 0); +}); + +test("polling configuration changes restart browser task polling", async () => { + const harness = createHarness(); + await settle(); + harness.resetAlarmCreateCount(); + + harness.emitStorageChange({ + taskPollingEnabled: { oldValue: false, newValue: true }, + }); + await settle(); + + assert.equal(harness.getAlarmCreateCount(), 1); +}); + +test("service discovery prefers the API paired with the open zncm tab", async () => { + const harness = createHarness(); + + assert.deepEqual( + Array.from(harness.getApiCandidates("http://localhost:5174", "http://47.94.209.59:2026")), + [ + "http://localhost:5174", + "http://localhost:8001", + "http://47.94.209.59:2026", + "http://localhost:8001", + ].filter((value, index, values) => values.indexOf(value) === index), + ); +}); diff --git a/browser-extension/deer-web-clipper/content.js b/browser-extension/deer-web-clipper/content.js index 1c387461..abb388bc 100644 --- a/browser-extension/deer-web-clipper/content.js +++ b/browser-extension/deer-web-clipper/content.js @@ -7,6 +7,8 @@ const HOST_ID = "zncm-page-assistant-host"; const state = { enabled: true, + taskPollingEnabled: true, + webBaseUrl: "", open: false, busy: false, threadId: "", @@ -709,20 +711,58 @@ async function loadSettings() { const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS); state.enabled = Boolean(settings.pageAssistantEnabled); + state.taskPollingEnabled = Boolean(settings.taskPollingEnabled); + state.webBaseUrl = deerNormalizeBaseUrl(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl); ensureWidget(); } chrome.storage.onChanged.addListener((changes, areaName) => { - if (areaName !== "sync" || !changes.pageAssistantEnabled) { + if (areaName !== "sync") { return; } - state.enabled = Boolean(changes.pageAssistantEnabled.newValue); - ensureWidget(); - if (state.enabled) { - setStatus("页面问答框已开启。"); + if (changes.pageAssistantEnabled) { + state.enabled = Boolean(changes.pageAssistantEnabled.newValue); + ensureWidget(); + if (state.enabled) { + setStatus("页面问答框已开启。"); + } + } + if (changes.taskPollingEnabled) { + state.taskPollingEnabled = Boolean(changes.taskPollingEnabled.newValue); + } + if (changes.webBaseUrl) { + state.webBaseUrl = deerNormalizeBaseUrl(changes.webBaseUrl.newValue || DEER_CLIPPER_DEFAULTS.webBaseUrl); } }); + function isZncmPage() { + const appPath = `${location.pathname}${location.hash}`.toLowerCase(); + if ( + appPath.includes("/page/workspace") || + appPath.includes("/chats/") || + appPath.includes("#/login") + ) { + return true; + } + try { + const configured = new URL(state.webBaseUrl); + return configured.origin === location.origin; + } catch { + return false; + } + } + + // MV3 service workers may be suspended between their short polling timers, + // while chrome.alarms cannot reliably run more often than every 30 seconds. + // A visible zncm tab provides a cheap wake-up signal so browser_act requests + // are normally claimed within the backend/model timeout window. + setInterval(() => { + if (!state.taskPollingEnabled || !isZncmPage()) { + return; + } + chrome.runtime.sendMessage({ type: "zncm-poll-now" }).catch(() => {}); + }, 3000); + setInterval(() => { if (!state.enabled || !ui.pageTitle) { return; diff --git a/docs/WIKI_ROUNDTRIP_ACCEPTANCE_20260903.md b/docs/WIKI_ROUNDTRIP_ACCEPTANCE_20260903.md new file mode 100644 index 00000000..21d1cde2 --- /dev/null +++ b/docs/WIKI_ROUNDTRIP_ACCEPTANCE_20260903.md @@ -0,0 +1,117 @@ +# Wiki 数据流转与本地编码验收(2026-09-03) + +## 结论 + +本地 BGE-M3 小批量编码已安装、配置并实际运行。已有 Wiki 的向量化、自动沉淀、 +普通库文件导出、助手库异步导入、助手库文件导出、空普通库回流及两端向量检索已实测通过。 +**技能自动生成 Wiki 尚未完成验收**:WeKnora 的两个生成模型都返回 HTTP 402 +`Insufficient Balance`。不能把后半段通过等同于全链路通过。 + +本次修改运行在本地 DeerFlow 开发服务(前端 5175、后端 8001),连接用户提供的远端 +WeKnora;没有把业务代码发布到服务器,没有提交 Git,也没有直接写 WeKnora 图数据库。 + +## 本地编码配置 + +- 实现:FastEmbed / ONNX Runtime CPU,官方 `BAAI/bge-m3` 权重。 +- 配置名:`bge-embedding-m3`,1024 维,每批 2 条,2 个 CPU 推理线程。 +- 运行依赖:FastEmbed 随标准后端依赖安装;模型必须提前放在离线目录中,仍按需加载,不影响主服务启动。 +- 权重:约 2.3 GB,持久缓存 `C:\Users\ji\.cache\deerflow\embeddings`,不进入业务仓库。 +- 配置:`llmwiki.local_wiki_index.embedding.provider: local`;可用 + `local_model_path` 指定离线权重目录,详见后端 README。 +- 查询向量在本地实时编码。已有 Wiki 在内容哈希、模型指纹匹配且向量非空时直接复用。 + 不同模型或维度不能混用;导入不静默重新编码。 +- 原始文档分段向量与模型生成后的 Wiki 文本不同,不能相互冒用。本次新建的是 Wiki + 正文的 BGE-M3 索引,不是把既有 512 维文档向量改名为 1024 维。 + +## 实际数据验收 + +来源是已有普通知识库“图片测试111”,没有改写其 Wiki 内容。 + +| 阶段 | Wiki | Wiki 向量 | 实体 | 引用关系 | +| --- | ---: | ---: | ---: | ---: | +| 普通知识库导出 | 19 | 101 | 19 | 108 | +| 助手知识库导入后导出 | 19 | 101 | 19 | 108 | +| 新建普通知识库回流后导出 | 19 | 101 | 19 | 108 | +| 知识梳理总库导出(7 个来源记录) | 19 | 101 | 19 | 108 | + +逐项断言通过: + +1. 三份包的 Wiki slug、标题、正文、摘要、页面类型、别名、分类路径、出链一致。 +2. 101 条向量的原始 float32 字节、分段编号、维度和模型指纹完全一致。 +3. 将导入前后实体 ID 映射回原 Wiki slug 后,108 条关系的端点及谓词集合完全一致。 +4. 总库导出没有因多个来源重复同步而重复导出同一当前页面的分段向量。 +5. 元数据回归测试另覆盖 tags、目录恢复、原始文档片段不计入 Wiki 数量、原生技能无 + ownership 记录时可被选中、短 Wiki 不漏向量、模型指纹不兼容时不假装向量检索。 + +普通包约 1.01 MB,助手包约 0.79 MB,回流包约 0.83 MB。普通包还带 2 个原始文档及 +108 个原始片段;助手导出提供 Wiki、Wiki 向量和关系,不重新上传原始文档触发二次生成。 + +### 检索 + +实际查询:“军方用什么工具评估极端天气带来的风险?” + +- 回流普通库 `/api/llmwiki/search` 返回 `mode=vector`,首条“国防气候评估工具”。 +- 助手库 `/api/assistant-knowledge/search` 返回 `retrieval_mode=vector`,首条同上; + 首个命中分段余弦相似度约 0.6343。 +- 浏览器实际提交总库检索,显示“向量检索 · 余弦相似度”和相同命中;不是只检查返回字段。 +- 普通库采用已有页面级聚合评分,因此页面最终分数与单分段余弦值不必相等。 +- 回流的向量写入 **DeerFlow 管理的本地 Wiki 索引**。未写入 WeKnora 原生向量数据库, + 本次结论适用于本系统问答检索,不能声称原生 WeKnora 自身的检索也已完成该向量导入。 + +### 页面 + +普通与助手库复用同一 Wiki 阅读组件:目录树、类型/标签筛选、分类面包屑、别名标签、 +正文内链、出链、反向链接及邻接关系视图。窄窗口可收起目录。 + +浏览器验证了“索引 → 国防气候评估工具”、展开关系视图、助手 Wiki 跳回普通库来源, +以及普通 Wiki 的“在知识梳理中查看”反向跳转。 + +### 验收数据位置 + +- 原有普通库:`48f669dc-433e-4aab-b5ea-897b6f7a3d2f` +- 知识梳理总库:`c3fd9841-0718-44dc-860e-aae3020fd485` +- 最终助手测试库:`489be905-08c8-40fe-8d60-42ac21dd945a` +- 最终普通回流测试库:`428162aa-03f6-4c61-8994-360f2f39e0c9` + +新建测试库名称均以“Wiki链路验收-0903-1826”开头,保留用于复核;未删除用户已有库。 +本地 `.runtime/wiki-live-result.json` 保存验收结果,`.runtime/wiki_live_acceptance.py` +保存本次验收流程,均为忽略的运行产物,不包含认证口令。 + +## 这次实际修复的问题 + +- 内置技能只有磁盘目录、没有归属记录时,被错误判定为不存在。 +- 技能应上传去代码后的知识正文,让 WeKnora 生成 Wiki,而不是伪造一个 `skill` 类型页面。 +- Wiki 生成等待使用实际 stats 接口;零页结果明确失败,不无期限等待。 +- 向量化完成后自动导出沉淀到总库,并阻断导出回调重复触发全量同步。 +- 短摘要和短正文同时存在时,旧分段阈值会将整页过滤为零向量。 +- 分类路径必须先创建 WeKnora folder 并绑定 folder_id;只传 category_path 会被服务端清空。 +- Wiki 的标签/分类/别名/原始引用保留;图节点绑定真实 Wiki,避免生成重复的空实体页面。 +- 保留归一化 float32 原始字节;导入中不能用 `section_index or index`,因为合法编号 0 会被改写。 +- 总库多个来源保留审计,但检索与导出只使用当前 Wiki 版本对应的向量,避免旧贡献污染检索。 +- 包解析改为同盘临时 SQLite,上传先落盘再异步导入,导出完成后才提供文件下载。 + +## 阻塞与未通过的范围 + +1. 技能 `knowledge-base-ingest` 已送入专门创建的普通测试库,但远端 + `deepseek-v4-flash` 和 `qwen3.6-flash` 均因余额不足失败。WeKnora 即使失败也可能把 + 文档 parse_status 标为 completed。需恢复可用生成模型后重新运行技能全链路验收。 +2. 已采用磁盘暂存和有界批处理,但本次只有约 1 MB 实测,**未做数 GB 压测、断点续传、 + 进程中断恢复验收**。大包部署仍需磁盘容量、代理 body size 和超时配置。 +3. 当前实体去重主要按类型及规范化名称;这次没有完成跨别名、歧义实体的模型对齐及 + 多来源事实冲突合并验收,不能视为这一历史需求已全面完成。 +4. 重复上传仍会保留独立来源记录。向量导出已消除旧版本重复,但上传身份幂等、部分远端 + 写入失败后的无损重试、跨实例图片资源打包仍需独立验证。 +5. 当前版本已补充自动增量链路:服务启动立即补扫,之后默认每 30 秒扫描全部普通 + 知识库;Wiki 抽取完成后无需手工点击向量化,并自动以来源快照覆盖同步到知识梳理总库。 +6. 回流当前只允许新建空 Wiki 普通库,避免覆盖已有页面。普通库现有页面列表的超大规模 + 分页与助手问答的最终生成回答不属于这轮通过结论。 + +## 自动化检查 + +- 后端相关测试:118 passed(assistant repository、Wiki sync、package roundtrip、 + WeKnora 接口、向量检索与知识库管理)。 +- 修改的 Python 功能文件 Ruff 检查通过;app.py 既有启动顺序导致的 E402 不在此次改动中处理。 +- `git diff --check` 通过(只有仓库换行提示)。 +- 前端完整 `pnpm typecheck` 未通过:57 个既有/本轮范围外错误,涉及 nextra 缺失、Markdown + 插件类型及其他模块;本轮新建/修改的 Wiki 组件、助手 API、技能面板没有出现在错误清单中。 + 浏览器实际页面与跳转已验证,但不能声称全项目构建通过。 diff --git a/frontend-web/docs/skill-knowledge-and-assistant-knowledge.md b/frontend-web/docs/skill-knowledge-and-assistant-knowledge.md new file mode 100644 index 00000000..8d929308 --- /dev/null +++ b/frontend-web/docs/skill-knowledge-and-assistant-knowledge.md @@ -0,0 +1,7 @@ +# 技能知识归纳与“知识梳理”助手知识库 + +管理员在「技能管理 → 技能知识归纳」中选择一个或多个技能、一个或多个目标,系统按“技能 × 目标”创建后台任务。界面提供总体进度、单项失败重试、实体/关系审核、绑定重归纳、自动同步和两种解绑动作。技能列表通过一次 bindings 查询展示归纳目标,避免逐技能请求。 + +扫描仅处理知识资料。代码、脚本、锁文件、密钥文件以及 Markdown 代码块不会进入上传、摘要、提示词或知识指纹;ZIP 只读取逐项验证通过的知识成员。自动监听只比较该知识指纹,所以纯代码改动不会产生 stale 状态。 + +知识库页面新增「普通知识库 / 助手知识库」并行入口。管理员可将系统默认入口设为 v1(普通 WeKnora)或 v2(全局“知识梳理”)。助手库无需 WeKnora 即可初始化,所有用户可只读查看和搜索 Wiki;管理员可上传本地文件、从普通库导入、重新导入或删除来源、查看版本并回滚。来源删除只移除对应贡献,共享实体与关系继续保留。 diff --git a/frontend-web/docs/技能归纳至WeKnora知识库-实现设计.md b/frontend-web/docs/技能归纳至WeKnora知识库-实现设计.md new file mode 100644 index 00000000..c2f22081 --- /dev/null +++ b/frontend-web/docs/技能归纳至WeKnora知识库-实现设计.md @@ -0,0 +1,2543 @@ +# 技能归纳、WeKnora 普通知识库与助手知识库:实现设计 + +本文档用于指导大模型或开发者实现知识库体系改造。整体包含两条线:一条是“将一个或多个技能归纳到一个 WeKnora Wiki 普通知识库”;另一条是新增自研助手知识库体系。助手知识库像普通知识库一样可以创建多个库,同时系统内置一个全局大库,用于统计全库数据、承接所有 WeKnora 导入沉淀、做全局实体对齐、Wiki 展示和导入审计。 + +本文档中,技能归纳目标只能是 WeKnora Wiki 普通知识库;助手知识库不接收技能直接归纳,也不接收任意本地文件。普通知识库负责向量化、检索、Wiki 和图谱;助手知识库负责自研存储、WeKnora 导入、实体对齐、Wiki 展示、搜索和导入审计。无论 WeKnora 数据导入到哪个助手知识库,最终都必须沉淀一份到系统全局大库。 + +> 最终约束:如果本文历史章节与本节冲突,以本节和“2. 当前边界”中的约束为准。 + +## 1. 背景与目标 + +当前系统已有: + +- 技能管理:后台可管理运行中的技能,技能文件位于 `skills/public`、`skills/custom`。 +- 技能地址管理:已经在管理员技能管理页中以标签页形式接入,支持扫描技能内 `.md`、`.py` 中的地址并批量替换。 +- WeKnora / LLM Wiki 接入:前后端已有知识库、文档、Wiki 页面、Wiki 图谱等 API。 + +本次要新增一条管理员工作流: + +管理员在“技能管理”页面选择多个技能,再选择一个目标 WeKnora Wiki 普通知识库,点击归纳。系统将每个技能作为独立来源读取、解析、抽取和归纳,把结果写入对应 WeKnora 知识库,并在技能列表和技能详情中展示“该技能已被哪些知识库归纳”。如果需要归纳到多个普通知识库,通过多次操作完成。 + +用户已确认的业务约束: + +- 一次操作可以选择多个技能,但只能选择一个目标 WeKnora Wiki 普通知识库。 +- 同一个技能可以在不同时间归纳到多个知识库,所以最终关系是技能与知识库多对多。 +- 多知识库归纳通过多次操作完成;每次操作形成一批 `技能 x 单目标知识库` 任务。 +- V1 只支持归纳到可写 WeKnora Wiki 普通知识库;不支持非 Wiki、FAQ、系统会话存档、只读知识库作为技能归纳目标。 +- 技能归纳时只抽取数据、业务说明、实体、概念、对象、事件、关系等知识;Python、TypeScript、Shell 等代码和脚本类文件完全排除,不读取注释、不抽取函数、不上传、不进入 prompt。 +- 技能里的 Markdown、业务数据文件、表格、PDF、Office、图片等要纳入处理范围。 +- 需要同步三类内容:原始资料、归纳后的 Wiki、实体与关系。 +- 关系来源包括 Markdown 自然语言描述、目录和文件引用、模型自动推断。 +- 允许模型推断关系,但前端不提供逐条实体/关系预览审核。 +- 支持管理员手动点击“重新归纳”;不新增自动文件监听、定时后台同步或技能变更自动远程写入。 +- 解除关联时弹窗让用户选择:保留知识库内容,或删除该技能贡献的内容。 +- 权限仅管理员。 +- 总技能数约 200 个,需要支持批量进度、后台执行、失败重试。 +- 同一目标知识库内,不同技能抽取出的同一实体需要合并。 +- 重归纳时先生成新版本,成功后再替换旧版本;失败时旧版本继续可用。 +- 新增知识库类型区分:普通知识库是 WeKnora,助手知识库是自研存储。 +- 助手知识库支持创建多个库;其中有且只有一个系统全局大库,建议命名为“知识梳理总库”,所有用户可见。 +- 助手知识库只提供 Wiki 页面展示、搜索和数据导入;不落到 WeKnora。 +- 助手知识库数据来源只能是 WeKnora 导出包或从某个 WeKnora 知识库触发导入。 +- WeKnora 已向量化和处理完成的 Wiki、文档、实体、关系、图谱数据默认沉淀到助手知识库;如果导入的是某个自建助手库,也必须同步沉淀到全局大库。 +- 普通知识库需要提供导出或“导入到助手知识库”的功能,把 WeKnora 里已经处理好的数据导入指定助手知识库;导出数据需要包含 Wiki、文档、chunk、实体、关系、图谱。 +- 即使没有配置 WeKnora 地址,也要默认展示助手知识库列表和创建入口,而不是展示空的普通知识库页面。 +- 管理员可切换知识库产品版本:`v1` / `v2`,默认 `v1`。 +- 助手知识库普通用户只读,管理员可导入、编辑、删除、查看版本和回滚 Wiki 页面。 + +## 2. 当前边界 + +以下能力明确不做,开发实现和验收必须按这些边界收敛: + +- 不支持一次选择多个目标知识库。多知识库归纳通过多次操作完成。 +- 不支持非 Wiki 知识库、FAQ 知识库、系统会话存档知识库、只读知识库作为技能归纳目标。 +- 不直接写 WeKnora 的 Neo4j 或底层图数据库。 +- 不新增自动文件监听、定时后台同步或技能变更自动远程写入。 +- 不在前端提供逐条实体/关系预览审核。 +- 不把远端 WeKnora 内部 ID 暴露为前端业务主键。 +- 不支持技能直接归纳到助手知识库。技能先进入 WeKnora 普通知识库,再由 WeKnora 导出沉淀到助手知识库。 +- 不支持管理员向助手知识库导入任意本地文件;手动导入也必须是 WeKnora 导出包或从某个 WeKnora 知识库触发。 +- 助手知识库 V1 不做页面版本管理,只保留当前合并后的 Wiki 状态和导入审计记录。 + +## 3. 术语 + +| 术语 | 含义 | +| --- | --- | +| 技能 | DeerFlow 技能目录,通常包含 `SKILL.md`、代码、配置、示例、文档、数据文件等。 | +| 目标知识库 | 管理员选择的一个 WeKnora Wiki 普通知识库。助手知识库不是技能归纳目标。 | +| 普通知识库 | WeKnora 知识库,负责向量化、Wiki、文档检索和图谱能力。 | +| 助手知识库 | DeerFlow 自研知识库类型,支持创建多个库,用于 Wiki 展示、搜索、WeKnora 导入和实体对齐。 | +| 知识梳理总库 | 助手知识库体系中的系统全局大库,有且只有一个,自动统计和沉淀所有助手库与普通知识库导入数据,做全局实体对齐。 | +| 自建助手库 | 管理员创建的普通助手知识库,类似普通知识库列表中的一个库,可导入 WeKnora 普通知识库导出数据。 | +| 产品版本 | 知识库产品形态开关。`v1` 默认为普通知识库体验,`v2` 默认为助手知识库体验。 | +| WeKnora 导出包 | 从普通知识库导出的已处理知识集合,包含 Wiki 页面、文档、chunk、实体、关系、图谱等。 | +| 绑定 | 一个技能与一个目标知识库之间的持久关联。`skill_name + target_type + target_id` 唯一。 | +| 快照 | 某次扫描技能目录得到的内容版本,包含文件清单、摘要、抽取结果和源 digest。 | +| 贡献 | 某个技能快照对目标知识库内实体、关系、Wiki 页面、原始资料的增量内容。 | +| 规范实体 | 在同一个目标知识库内合并后的实体。多个技能可以贡献到同一个实体。 | +| 规范关系 | 在同一个目标知识库内合并后的关系。多个技能可以给同一关系提供证据。 | +| 归纳任务 | 一次管理员发起的批量操作,可包含多个目标知识库和多个技能 item。 | +| item | 归纳任务中的单个技能处理单元。 | + +## 4. 总体方案 + +整体采用“双知识库形态 + 本地持久绑定 + 后台任务 + WeKnora 投影 + 助手底库实体对齐”的架构。 + +```mermaid +flowchart LR + A[管理员选择多个技能] --> B[选择一个 WeKnora Wiki 目标知识库] + B --> C[创建归纳任务] + C --> D[逐技能扫描与解析] + D --> E[生成技能快照] + E --> F[LLM 归纳 Wiki 与关系抽取] + F --> G[按目标 WeKnora Wiki 合并实体/关系] + G --> H[写入 WeKnora 原始资料与 Wiki] + G --> I[写入关系投影文档] + H --> K[校验远端写入] + I --> K + K --> L[本地原子激活新版本] + L --> M[技能页展示已归纳知识库] + K --> N[WeKnora 导出已处理数据] + N --> O[导入指定助手知识库] + O --> P[同步沉淀到知识梳理总库] + P --> Q[全局实体对齐] + Q --> S[总库 Wiki 展示与搜索] +``` + +关键原则: + +- DeerFlow 本地数据库保存权威绑定关系、版本状态、来源贡献和远端 artifact 映射。 +- WeKnora 保存可检索知识内容,包括原文、Wiki 页面、FAQ、会话存档、关系投影文档和图谱。 +- 不直接写 WeKnora 底层图数据库;关系以 WeKnora API 能力和关系投影文档承接。 +- 重归纳使用 staged artifact,远端写入和校验成功后,本地才激活新版本。 +- 删除或解除关联只影响该技能在目标知识库中的贡献,不误删其他技能共享的实体和关系。 +- 助手知识库可以创建多个库;系统全局大库只有一个,负责汇总全库数据和做跨库实体对齐。 +- WeKnora 的已处理结果可以导入指定助手知识库;无论是否指定自建助手库,都必须同步沉淀到知识梳理总库。 +- 未配置 WeKnora 地址时,默认进入助手知识库页面,展示助手知识库列表、创建入口和知识梳理总库;与 WeKnora 相关的导出导入动作置灰或隐藏。 + +## 5. 现有代码落点 + +实现前必须先读仓库说明: + +- `D:/cmzs-clean/deerflow-code/AGENTS.md` +- `D:/cmzs-clean/deerflow-code/offline-backend-20260512/backend/AGENTS.md` +- `D:/cmzs-clean/deerflow-code/offline-backend-20260512/backend/CLAUDE.md` + +建议改动位置: + +| 模块 | 建议文件 | +| --- | --- | +| 后端 router | `offline-backend-20260512/backend/app/gateway/routers/skill_knowledge.py` | +| 后台任务调度 | `offline-backend-20260512/backend/app/gateway/skill_knowledge_job_dispatcher.py` | +| 后台任务执行 | `offline-backend-20260512/backend/app/gateway/skill_knowledge_job_executor.py` | +| 技能归纳核心逻辑 | `offline-backend-20260512/backend/packages/harness/deerflow/skill_knowledge/` | +| 持久化模型 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/skill_knowledge/` | +| WeKnora 客户端扩展 | `offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/client.py` | +| 助手知识库核心逻辑 | `offline-backend-20260512/backend/packages/harness/deerflow/assistant_knowledge/` | +| 助手知识库持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/assistant_knowledge/` | +| 助手知识库 Gateway | `offline-backend-20260512/backend/app/gateway/routers/assistant_knowledge.py` | +| WeKnora 导出到助手库任务 | `offline-backend-20260512/backend/app/gateway/assistant_knowledge_import_executor.py` | +| 前端 API | `frontend-web/src/strategy-components/api/skill-knowledge.ts` | +| 助手知识库前端 API | `frontend-web/src/strategy-components/api/assistant-knowledge.ts` | +| 管理员技能页 | `frontend-web/src/pages/AdminSkillsPage.tsx` | +| 技能行组件 | `frontend-web/src/components/workspace/settings/skill-settings-page.tsx` | +| WeKnora API hooks | `frontend-web/src/strategy-components/api/llmwiki.ts` | +| 知识库产品版本设置 | `frontend-web/src/core/system-settings/api.ts` | + +注意边界: + +- `packages/harness/deerflow/*` 不能 import `app.*`。 +- 路由、鉴权、FastAPI app state 等放在 `app/gateway/*`。 +- 纯扫描、解析、抽取、合并、投影计划生成放在 `deerflow.*`。 +- 文件 IO、Office/PDF 转换、CPU 密集处理必须通过 `asyncio.to_thread`,避免阻塞事件循环。 + +## 6. WeKnora 能力使用策略 + +现有 WeKnora 客户端已支持: + +- 创建知识库。 +- 上传文件。 +- 导入 URL。 +- 创建手动文档。 +- 创建、更新、删除、查询 Wiki 页面。 +- 获取 Wiki 图谱。 + +V1 需要新增或补齐: + +- 目标知识库能力探测:是否启用 Wiki,是否可写,是否支持 GraphRAG / extract_config。 +- 目标知识库分类和能力标识:Wiki、非 Wiki、FAQ、会话存档、只读、图谱可写。 +- 导出已处理数据的能力:Wiki 页面、文档、chunk、实体、关系、图谱。 +- WeKnora 处理完成后的自动沉淀触发点:在文件向量化、Wiki 生成和图谱抽取完成后,创建“导入助手知识库”的后台任务。 +- 如果 WeKnora 当前实例 Swagger 暴露了图谱相关配置更新接口,则在目标知识库启用 `graph_enabled` 与 `extract_config`。 +- 如果未暴露稳定的实体/关系写入 API,优先使用“关系投影文档 + Wiki 关系页 + Wiki 链接”让 WeKnora 自己抽取图谱;在配置显式开启时,可通过受控 Neo4j direct writer 写入底层图数据库。 + +参考官方资料: + +- [WeKnora Knowledge Graph 文档](https://github.com/Tencent/WeKnora/blob/main/docs/KnowledgeGraph.md) +- [WeKnora knowledge-base API 文档](https://github.com/Tencent/WeKnora/blob/main/docs/api/knowledge-base.md) +- [WeKnora QA 文档](https://github.com/Tencent/WeKnora/blob/main/docs/QA.md) +- [WeKnora API 文档入口](https://github.com/Tencent/WeKnora/blob/main/docs/api/README.md) + +实现要求: + +- 以部署实例的 Swagger 为准,不要硬编码未确认的远端 API。 +- 可以封装 `probe_capabilities()`,返回 `wiki_enabled`、`graph_enabled_supported`、`extract_config_supported`、`wiki_page_upsert_supported`、`file_upload_supported` 等能力。 +- GraphRAG 失败不能导致 Wiki 归纳整体回滚。Wiki 与原始资料成功、GraphRAG 写入失败时,绑定状态为 `partial_failed`,错误信息明确写“图谱投影失败,可重新归纳重试”。 +- 如果 WeKnora 不支持直接导出向量、chunk 或图谱原始结构,导出层应退化为读取 Wiki 页面、文档状态、Wiki graph 和可查询的图谱摘要;助手知识库不强依赖复用 WeKnora embedding 向量。 + +### 6.1 目标知识库适配策略 + +一次归纳任务允许选择多个技能和多个目标知识库。后端不要把它当成一个大事务,而是展开为矩阵: + +```text +items = selected_skills x selected_targets +``` + +每个 item 对应一个 `binding`,一个技能归纳到三个目标知识库,就生成三个 binding。父 job 负责展示整体进度,item 独立成功、失败、取消和重试。 + +当目标是自建助手库时,item 写入顺序是: + +```text +写入目标助手库 -> 生成目标库 Wiki/实体/关系 -> 创建总库沉淀任务 -> 总库实体对齐和统计 +``` + +总库沉淀失败不能让目标助手库写入结果消失,但 item 必须标记为 `partial_failed`,并允许单独重试“沉淀到总库”。 + +目标知识库需要通过 adapter 写入: + +| 目标类型 | 写入策略 | Wiki | 关系/图谱 | +| --- | --- | --- | --- | +| WeKnora Wiki 知识库 | 上传原始资料、upsert Wiki 页面、写 graph projection | 写 WeKnora Wiki | WeKnora GraphRAG + 可选 Neo4j direct writer | +| WeKnora 非 Wiki 知识库 | 上传原始资料和归纳文档,不强制生成 Wiki | 不写 WeKnora Wiki,可写本地 sidecar Wiki | graph projection 文档 + 可选 Neo4j direct writer | +| WeKnora FAQ 知识库 | 把归纳结果转换为 FAQ 条目、来源文档和关系摘要 | 不写或只写 sidecar Wiki | 关系作为 FAQ metadata、projection 文档和图谱边 | +| 系统会话存档知识库 | 把技能归纳结果作为系统会话归档条目写入 | sidecar Wiki 展示 | 关系作为归档 metadata 和图谱边 | +| 只读 WeKnora 知识库 | 不修改远端原库;写本地 sidecar artifact,并生成待推送状态 | sidecar Wiki 展示 | sidecar graph + 待推送 Neo4j plan | +| 助手知识库 | 写入指定助手库、实体对齐、生成 Wiki 页面版本;同时异步沉淀到知识梳理总库 | 写自研 Wiki | 写自研实体/关系表;总库做跨库实体对齐 | + +只读目标说明: + +- “支持只读知识库作为目标”不代表绕过远端权限强行写入。 +- 对只读 WeKnora,系统创建本地 sidecar 投影:归纳结果与该远端知识库绑定展示,但远端写入状态为 `pending_remote_write`。 +- 当管理员后续授予可写权限时,可执行“推送到远端”,把 sidecar staged artifact 写入 WeKnora。 +- 前端必须清楚展示“只读目标:已本地归纳,未写入远端”。 + +### 6.2 Neo4j 直接写入设计 + +需求确认需要支持直接写 WeKnora 的 Neo4j 或底层图数据库。该能力风险较高,必须作为受控能力实现。 + +必须满足: + +- 通过配置显式开启,例如 `weknora.graph.direct_write_enabled = true`。 +- 只允许管理员触发。 +- Neo4j 连接串、账号、库名只允许后端配置,不允许前端传入。 +- LLM 不得生成任意 Cypher 并直接执行。 +- graph writer 只能执行代码中预定义的参数化模板。 +- 所有写入使用事务。 +- 每个节点和边都带稳定业务 key、来源 binding、snapshot、job、confidence 和 evidence。 +- 支持 dry-run plan、写入审计、失败重试、回滚计划。 + +节点 key 建议: + +```text +provider = deerflow +scope = target_type + target_id +entity_key = entity_type + normalized_name 或 external_id +``` + +边 key 建议: + +```text +source_entity_key + predicate + target_entity_key + contribution_scope +``` + +Neo4j 写入只作为图谱层加速和展示,不替代本地 contribution 表。本地 contribution 表仍是删除、回滚、重建、审计的权威来源。 + +### 6.3 实体/关系预览审核 + +前端必须提供逐条实体/关系预览审核能力。审核发生在“抽取完成、写入目标前”。 + +审核对象: + +- 实体。 +- 关系。 +- 实体对齐候选。 +- 关系合并候选。 +- Wiki 页面草稿。 + +管理员操作: + +- 批准。 +- 拒绝。 +- 编辑名称、类型、谓词、别名、摘要、confidence。 +- 批量批准高置信结果。 +- 批量拒绝低置信结果。 + +审核模式建议: + +| 模式 | 说明 | +| --- | --- | +| `required` | 所有实体/关系必须审核后才能写入。 | +| `auto_high_confidence` | 高置信自动通过,低置信进入审核队列。 | +| `off` | 不阻塞写入,但仍保留预览页面和事后审计。 | + +默认建议 `auto_high_confidence`。如果用户希望“必须逐条审核”,可在系统设置中改为 `required`。 + +审核记录必须入库: + +- 操作人。 +- 操作时间。 +- 原始抽取值。 +- 修改后值。 +- 通过或拒绝原因。 +- 对应 job/item/binding/snapshot。 + +### 6.4 自动同步设计 + +需要支持三类自动同步: + +- 文件监听:技能知识文件变化后自动创建归纳任务。 +- 定时同步:按管理员配置的周期重新扫描并归纳。 +- 技能变更自动远程写入:技能保存、启停、导入后触发相关 binding 同步。 + +自动同步仍需遵守: + +- 只处理非代码知识文件。 +- 代码文件变化不触发归纳。 +- 自动任务必须 debounced,避免保存过程中连续触发。 +- 自动任务必须走 staged 版本,成功后激活,失败保留旧版本。 +- 同一 binding 同一时间只能有一个 active item。 +- 审核模式为 `required` 时,自动任务停在 `awaiting_review`,不写远端。 + +建议新增配置: + +```json +{ + "skill_knowledge_auto_sync": { + "watch_enabled": true, + "schedule_enabled": true, + "schedule_cron": "0 */6 * * *", + "debounce_seconds": 120, + "write_remote_enabled": true + } +} +``` + +### 6.5 远端 ID 暴露与业务 key + +需求确认前端需要暴露 WeKnora 远端 ID,并可作为跨系统业务 key 使用。 + +实现要求: + +- API 返回 `remote_refs`,包含 provider、remote kind、remote id、slug、url。 +- 前端可以展示、复制、筛选远端 ID。 +- 与外部系统交互时,可使用 `provider + remote_id` 作为业务 key。 +- 本地数据库仍保留 `id` 作为 surrogate primary key,避免远端 ID 变更导致本地关系断裂。 + +建议响应结构: + +```json +{ + "id": "local-artifact-id", + "business_key": "weknora:wiki_page:remote-page-id", + "remote_refs": [ + { + "provider": "weknora", + "remote_kind": "wiki_page", + "remote_id": "remote-page-id", + "slug": "skills/example/overview", + "url": "/llmwiki/kb/xxx/wiki/skills/example/overview" + } + ] +} +``` + +不要把远端 ID 当作唯一存储来源;本地仍需要维护 mapping、审计和状态。 + +## 7. 数据处理范围 + +归纳一个技能时,扫描技能根目录下所有有效的非代码知识文件。代码类文件不作为知识源,也不影响知识 digest。 + +必须处理: + +| 类型 | 策略 | +| --- | --- | +| `.md` / `.mdx` / `.txt` | 直接读取,保留标题、段落、链接、表格和自然语言说明;代码块内容默认跳过,只保留“此处存在示例代码”的弱提示。 | +| `.json` / `.yaml` / `.yml` / `.toml` / `.ini` | 仅当它们承载业务数据、数据字典、枚举、模板或知识配置时处理;运行配置、依赖配置、工具入口、工程配置不入库。 | +| `.csv` / `.tsv` | 结构化读取,抽样概要 + 全量原始资料上传。 | +| `.xlsx` / `.xls` | 使用已有文件转换或表格解析能力,保留 sheet、列名、样例和统计。 | +| `.pdf` / `.docx` / `.pptx` | 复用现有 `deerflow.utils.file_conversion` 或 MarkItDown 路径转文本,同时上传原始文件。 | +| 图片 | 上传原始文件;如果已有 VLM/OCR 能力则抽取图片描述,否则记录为二进制附件。 | +| 压缩包 | 安全解压到临时目录后递归处理,必须限制大小、文件数、路径穿越、符号链接。 | +| 其他二进制 | 记录 manifest 并尝试作为原始附件上传;无法解析时不生成文本摘要。 | + +必须排除: + +- `.py` +- `.ts` +- `.tsx` +- `.js` +- `.jsx` +- `.sh` +- `.bat` +- `.ps1` +- 其他脚本、源码、构建脚本、依赖锁文件 +- `.git` +- `node_modules` +- `__pycache__` +- `.pytest_cache` +- `dist` +- `build` +- 临时缓存目录 +- 明确生成物和大体积模型文件 + +必须阻断: + +- `.env` +- 私钥、公钥私钥对、证书私钥 +- token、password、secret、access key 等高置信密钥文件 + +阻断规则是安全底线:发现疑似密钥时,该文件不能上传到 WeKnora,也不能进入 LLM prompt。任务 item 标记为 `partial_failed` 或 `failed`,前端展示“因安全策略跳过敏感文件”,并给出文件路径,不输出密钥内容。 + +代码排除规则: + +- 不读取源码内容。 +- 不抽取源码注释。 +- 不抽取函数、类、import、依赖、入口、调用链。 +- 不上传源码文件。 +- 不把源码路径作为 Wiki 知识展示;最多在管理员任务详情中显示“已跳过代码类文件数量”。 +- 计算 `source_digest` 时只包含知识文件;纯代码变更不应导致技能归纳状态变为 stale。 + +## 8. 中间表示 IR + +扫描和抽取后生成统一 IR,后续 Wiki 生成、实体合并、远端投影都基于 IR,而不是直接从文件内容临时拼装。 + +建议结构: + +```json +{ + "skill": { + "name": "example-skill", + "display_name": "示例技能", + "description": "从 SKILL.md 和元数据提取", + "source_root": "skills/public/example-skill", + "source_digest": "sha256:..." + }, + "files": [ + { + "path": "SKILL.md", + "kind": "markdown", + "sha256": "...", + "size": 12345, + "text_digest": "sha256:...", + "title": "Example Skill", + "summary": "文件摘要", + "anchors": [ + { + "id": "heading-install", + "line_start": 12, + "line_end": 36, + "text_digest": "sha256:..." + } + ], + "references": [ + { + "target_path": "data/schema.yaml", + "evidence": "Markdown link or import", + "confidence": 1.0 + } + ], + "safety": { + "status": "allowed", + "warnings": [] + } + } + ], + "entities": [ + { + "local_id": "ent_001", + "type": "tool", + "name": "web_search", + "aliases": ["网络搜索"], + "description": "用于检索外部信息的工具", + "attributes": { + "entrypoint": "config.yaml tools.web_search" + }, + "evidence": [ + { + "file_path": "config.yaml", + "line_start": 10, + "line_end": 20, + "quote": "短证据,不包含敏感信息" + } + ], + "confidence": 0.94 + } + ], + "relations": [ + { + "local_id": "rel_001", + "source_local_id": "ent_001", + "predicate": "used_by", + "target_local_id": "ent_002", + "description": "web_search 被示例技能用于首步检索", + "relation_source": "llm_inferred", + "evidence": [ + { + "file_path": "SKILL.md", + "line_start": 22, + "line_end": 28, + "quote": "短证据" + } + ], + "confidence": 0.88 + } + ], + "wiki": { + "skill_page": { + "slug": "skills/example-skill/overview", + "title": "示例技能", + "content": "Markdown Wiki 内容" + }, + "pages": [] + } +} +``` + +IR 需要版本化: + +- `extractor_version`:扫描、解析、提示词和抽取 schema 的整体版本。 +- `prompt_hash`:LLM 抽取提示词 hash。 +- `model_name`:抽取模型名。 +- `source_digest`:技能目录内容 digest。 + +只要 `source_digest + extractor_version + prompt_hash + model_name` 相同,就可以复用已有 snapshot,避免 200 个技能批量时反复抽取。 + +## 9. 关系抽取规则 + +关系来源分三类: + +| 来源 | 示例 | 置信度策略 | +| --- | --- | --- | +| 确定性结构关系 | 目录包含文件、Markdown 链接、数据文件引用、业务配置引用 | `confidence = 1.0` | +| 显式文本关系 | Markdown 中写明“本技能依赖 A”、“输出 B”、“调用接口 C” | LLM 抽取,但要求证据;通常 `0.75` 以上 | +| 模型推断关系 | 从上下文推断某工具服务于某步骤、某配置控制某行为 | 必须有证据片段和解释;无证据不得入库 | + +无人工审核的前提下,必须设置阈值: + +- `confidence >= 0.75`:写入 Wiki 关系页和图谱投影文档。 +- `0.50 <= confidence < 0.75`:只写入技能 Wiki 的“可能关系”小节,不进入规范关系。 +- `confidence < 0.50`:丢弃。 + +每条关系都必须包含: + +- `source` +- `predicate` +- `target` +- `confidence` +- `relation_source` +- `evidence` + +如果没有证据路径和证据文本,不允许写入规范关系。 + +建议谓词归一化: + +| 原始表达 | 规范 predicate | +| --- | --- | +| 依赖、需要、requires | `depends_on` | +| 调用、使用、uses | `uses` | +| 生成、输出、produces | `produces` | +| 读取、输入、consumes | `consumes` | +| 配置、控制 | `configures` | +| 包含、目录下有 | `contains` | +| 引用、链接到 | `references` | +| 替换、迁移到 | `replaces` | +| 属于 | `belongs_to` | + +实现可以先内置有限谓词表,再允许保留 `custom_predicate`,但 UI 与关系页要展示规范谓词。 + +## 10. 同一知识库内的实体合并 + +实体合并范围只在一个目标知识库内生效,不跨知识库合并。 + +规范实体 key: + +```text +kb_mapping_id + entity_type + normalized_name +``` + +`normalized_name` 规则: + +- Unicode NFKC 归一化。 +- trim。 +- 多空白折叠成单空格。 +- ASCII 字母转小写。 +- 中英文标点归一化。 +- 去除无意义包裹符号,例如反引号、引号。 + +如果实体带有明确外部 ID,可优先使用: + +```text +kb_mapping_id + entity_type + external_id_type + external_id +``` + +合并规则: + +- `normalized_name` 相同且 `entity_type` 兼容:合并。 +- 名称相同但类型不兼容:不合并,例如 `config:web_search` 和 `tool:web_search` 可保持两个实体,必要时用 `configures` 关系连接。 +- LLM 判断别名合并:只有 `confidence >= 0.90` 且有明确证据时允许。 +- 属性冲突不做最后写入覆盖,必须按来源保留 provenance。 + +实体贡献示例: + +```json +{ + "canonical_entity_id": "ce_123", + "binding_id": "bind_001", + "snapshot_id": "snap_001", + "local_entity_id": "ent_001", + "attributes": { + "entrypoint": { + "value": "config.yaml tools.web_search", + "evidence": "config.yaml:10" + } + }, + "confidence": 0.94 +} +``` + +删除贡献时: + +- 只删除该 binding 的贡献。 +- 如果规范实体仍有其他贡献,保留实体页并重新生成聚合内容。 +- 如果规范实体贡献数为 0,删除或标记归档该规范实体及其 Wiki 页面。 + +## 11. 关系合并 + +规范关系 key: + +```text +kb_mapping_id + source_canonical_entity_id + normalized_predicate + target_canonical_entity_id +``` + +合并规则: + +- 同一 key 的关系合并为一条规范关系。 +- 每个技能的证据作为 relation contribution 追加。 +- `confidence` 可存储最大值、平均值和证据数,但展示优先显示最高置信证据。 +- 如果来源实体或目标实体被删除到无贡献,相关关系也需要重新计算。 + +关系 Wiki 页面内容必须保留: + +- 关系三元组。 +- 关系说明。 +- 贡献技能列表。 +- 证据路径。 +- 置信度。 +- 最近更新时间。 + +## 12. WeKnora 内容投影 + +每个技能归纳到一个目标知识库后,按目标 adapter 写入三类内容。目标是 WeKnora 时写入 WeKnora;目标是助手知识库或只读 sidecar 时写入 DeerFlow 自研存储。 + +### 12.1 原始资料 + +原始资料分两种: + +- 可直接上传的文件:走 WeKnora 文件上传。 +- 纯文本或需要展开的内容:创建手动文档,保留路径和 fenced code。 + +原始资料命名建议: + +```text +skills/{skill_name}/source/{relative_path} +``` + +文档 metadata 建议: + +```json +{ + "source_type": "deerflow_skill", + "skill_name": "example-skill", + "binding_id": "bind_001", + "snapshot_id": "snap_001", + "source_digest": "sha256:...", + "relative_path": "SKILL.md", + "artifact_kind": "raw_source" +} +``` + +如果 WeKnora 不支持自定义 metadata,则本地 artifact 表必须保存远端 ID、slug、digest、kind 和来源关系。 + +### 12.2 技能 Wiki 页面 + +每个技能至少生成: + +```text +skills/{skill_name}/overview +skills/{skill_name}/files +skills/{skill_name}/entities +skills/{skill_name}/relations +skills/{skill_name}/evidence +``` + +内容要求: + +- `overview`:技能用途、输入、输出、依赖、使用方式、风险提示。 +- `files`:重要文件清单、每个文件摘要、关键入口。 +- `entities`:该技能贡献的实体,使用 Wiki 链接指向规范实体页。 +- `relations`:确定关系和高置信推断关系,使用 Wiki 链接指向规范关系页。 +- `evidence`:证据目录,只包含短摘录和位置,不包含敏感内容。 + +### 12.3 规范实体 Wiki 页面 + +规范实体页 slug: + +```text +entities/{entity_type}/{stable_entity_id}-{slugified_name} +``` + +页面内容: + +- 实体名称和类型。 +- 别名。 +- 归并来源技能。 +- 聚合描述。 +- 属性表,冲突属性按来源列出。 +- 相关关系列表。 +- 证据索引。 + +### 12.4 规范关系 Wiki 页面 + +规范关系页 slug: + +```text +relations/{predicate}/{stable_relation_id} +``` + +页面内容: + +- `[[source_entity]] --predicate--> [[target_entity]]` +- 中文说明。 +- 贡献技能。 +- 证据。 +- 置信度。 +- 更新时间。 + +说明:即便 WeKnora Wiki graph 只能展示无类型链接,关系类型也不能丢。类型和证据由关系页保存,Wiki 链接负责把实体页和关系页连接起来。 + +### 12.5 图谱投影文档 + +为提高 WeKnora GraphRAG 抽取成功率,每个 binding 生成一份机器可读关系文档: + +```text +skills/{skill_name}/graph-projection.jsonl +``` + +每行一个关系: + +```json +{ + "source": "web_search", + "source_type": "tool", + "predicate": "used_by", + "target": "example-skill", + "target_type": "skill", + "description": "web_search 被 example-skill 用于首步检索", + "confidence": 0.88, + "evidence": "SKILL.md:22-28" +} +``` + +同时生成一份人类可读 Markdown: + +```text +skills/{skill_name}/graph-projection.md +``` + +该文档使用短句和固定模板,降低 WeKnora 抽取歧义: + +```markdown +# 技能关系投影:example-skill + +- 实体 `web_search`(tool)与实体 `example-skill`(skill)存在 `used_by` 关系。 + - 说明:web_search 被 example-skill 用于首步检索。 + - 证据:SKILL.md:22-28 + - 置信度:0.88 +``` + +GraphRAG 可用时上传这些投影文档;Neo4j direct writer 开启时,同时把通过审核的规范实体和关系写入底层图数据库;两者都不可用时仍保留 Wiki 关系页和本地关系表。 + +## 13. 重归纳一致性 + +重归纳必须满足“成功后替换,失败不影响旧版本”。 + +流程: + +1. 读取当前 binding 和 active snapshot。 +2. 重新扫描技能目录,生成新 snapshot。 +3. 如果 digest 未变化且未强制重归纳,可以直接返回 `synced`。 +4. 在本地创建 staged artifact 记录。 +5. 写入 WeKnora 新版本原始资料、Wiki 页面、关系投影文档。 +6. 校验远端可读取或状态完成。 +7. 在一个数据库事务中: + - 将旧 artifact 标记为 `retired`。 + - 将 staged artifact 标记为 `active`。 + - 将 binding 的 `current_snapshot_id` 指向新 snapshot。 + - 更新实体和关系贡献。 + - 设置 binding 状态为 `synced` 或 `partial_failed`。 +8. 对旧远端文件执行 best-effort 清理。 +9. 清理失败只记录 warning,不回滚已经激活的新版本。 + +如果第 5 或第 6 步失败: + +- binding 仍指向旧 snapshot。 +- 旧 WeKnora 内容继续有效。 +- staged artifact 标记为 `failed`。 +- item 状态为 `failed` 或 `partial_failed`。 +- 前端显示错误,允许 retry / 重新归纳。 + +Wiki 页面建议采用稳定 slug upsert。原始资料如果 WeKnora 没有 update API,则创建新远端文档,确认成功后再删除旧远端文档。 + +## 14. 解除关联 + +解除一个技能与一个知识库的绑定时,前端必须弹窗让管理员选择: + +| 选项 | 行为 | +| --- | --- | +| 保留知识库内容 | 绑定标记为 `detached_keep`。WeKnora 远端内容、助手库 Wiki revision 或 sidecar 内容保留为冻结知识,不再随技能重归纳更新。技能页不再显示为“当前归纳”,但可在历史中显示“已解除,内容保留”。 | +| 删除该技能贡献的内容 | 删除该 binding 的原始资料、技能 Wiki 页、关系投影文档、助手库 Wiki revision contribution、sidecar artifact 和实体/关系贡献;共享实体/关系只减少该技能贡献,仍有其他贡献则重建聚合页,没有贡献才删除。 | + +如果删除远端内容部分失败: + +- 本地记录 artifact `delete_failed`。 +- binding 状态为 `detached_delete_pending_cleanup`。 +- 提供“重试清理”动作。 + +不要因为某个共享实体由该技能参与过,就删除其他技能还在使用的实体页。 + +## 15. 状态机 + +绑定状态建议: + +| 状态 | 含义 | +| --- | --- | +| `pending` | 已创建绑定,但尚未开始处理。 | +| `queued` | 已进入任务队列。 | +| `running` | 正在处理。 | +| `awaiting_review` | 抽取完成,等待管理员审核实体、关系或 Wiki 草稿。 | +| `synced` | 当前版本已成功归纳到目标知识库。 | +| `pending_remote_write` | 只读目标已完成本地 sidecar 归纳,等待远端具备写权限后推送。 | +| `stale` | 技能源文件已变化,但尚未手动重新归纳。 | +| `partial_failed` | 核心 Wiki/原始资料成功,但部分文件、图谱或清理失败。 | +| `failed` | 当前归纳失败,旧版本若存在则继续有效。 | +| `detached_keep` | 已解除关联,远端内容保留。 | +| `detached_delete_pending_cleanup` | 已解除关联,但删除远端内容未完全成功。 | +| `detached_deleted` | 已解除关联,远端贡献已清理。 | + +item 阶段建议: + +```text +scanning +parsing +summarizing +extracting +awaiting_review +merging +uploading_sources +writing_wiki +writing_graph_projection +verifying +activating +cleanup +completed +``` + +前端展示时,父任务进度由 item 数量和阶段加权计算,不要只显示一个不可解释的 spinner。 + +## 16. 持久化设计 + +下面是建议表结构,字段命名可按项目现有风格调整。 + +### 16.1 `skill_knowledge_bindings` + +保存技能与知识库的多对多关系。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `skill_name` | 技能名。 | +| `target_type` | `weknora` / `assistant`。 | +| `target_mode` | `wiki` / `document` / `faq` / `conversation_archive` / `readonly_sidecar`。 | +| `target_id` | 目标知识库 ID。WeKnora 使用本地 mapping ID,助手库使用全局 base ID。 | +| `remote_business_key` | 暴露给前端和外部系统的远端业务 key,例如 `weknora:kb:xxx`,可为空。 | +| `status` | 绑定状态。 | +| `active` | 是否当前有效绑定。 | +| `current_snapshot_id` | 当前激活的技能快照。 | +| `source_digest` | 最近扫描到的技能源 digest。 | +| `synced_digest` | 最近成功归纳的 digest。 | +| `last_job_id` | 最近一次父任务。 | +| `last_item_id` | 最近一次 item。 | +| `last_synced_at` | 最近成功归纳时间。 | +| `last_error_code` | 最近错误码。 | +| `last_error_message` | 最近错误摘要。 | +| `created_by` | 管理员用户。 | +| `created_at` / `updated_at` | 时间戳。 | + +约束: + +```text +unique(skill_name, target_type, target_id) +``` + +### 16.2 `skill_knowledge_snapshots` + +保存技能扫描和抽取结果。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `skill_name` | 技能名。 | +| `source_digest` | 技能目录 digest。 | +| `extractor_version` | 抽取器版本。 | +| `prompt_hash` | 提示词 hash。 | +| `model_name` | 模型名。 | +| `manifest_json` | 文件清单、hash、安全状态。 | +| `ir_json` | 抽取后的 IR;较大时可存 blob 引用。 | +| `status` | `ready` / `failed`。 | +| `counts_json` | 文件数、实体数、关系数、跳过数。 | +| `created_at` | 创建时间。 | + +约束: + +```text +unique(skill_name, source_digest, extractor_version, prompt_hash, model_name) +``` + +### 16.3 `skill_knowledge_sync_jobs` + +保存一次批量归纳任务。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `target_count` | 本次目标知识库数量。 | +| `targets_json` | 目标知识库列表,每项包含 `target_type`、`target_mode`、`target_id`、展示名和远端业务 key。 | +| `status` | `queued` / `running` / `completed` / `partial_failed` / `failed` / `canceled`。 | +| `total_count` | 技能数。 | +| `success_count` | 成功数。 | +| `partial_failed_count` | 部分失败数。 | +| `failed_count` | 失败数。 | +| `cancel_requested` | 是否请求取消。 | +| `created_by` | 管理员用户。 | +| `created_at` / `started_at` / `completed_at` | 时间戳。 | + +### 16.4 `skill_knowledge_sync_items` + +保存每个技能的任务项。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `job_id` | 父任务。 | +| `binding_id` | 绑定。 | +| `target_type` | 目标类型。 | +| `target_mode` | 目标模式。 | +| `target_id` | 目标 ID。 | +| `skill_name` | 技能名。 | +| `snapshot_id` | 本次输入快照。 | +| `status` | item 状态。 | +| `phase` | 当前阶段。 | +| `progress` | 0-100。 | +| `active_dedupe_key` | 活跃任务去重 key。 | +| `lease_owner` | 执行器标识。 | +| `lease_until` | 租约过期时间。 | +| `attempts` | 尝试次数。 | +| `last_error_code` | 错误码。 | +| `last_error_message` | 错误摘要。 | +| `created_at` / `updated_at` / `started_at` / `completed_at` | 时间戳。 | + +活跃去重 key: + +```text +skill_name + target_type + target_id +``` + +同一绑定同一时间只能有一个 active item。 + +### 16.5 `skill_knowledge_artifacts` + +保存本地到 WeKnora 的远端 artifact 映射。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `target_type` | 目标类型。 | +| `target_mode` | 目标模式。 | +| `target_id` | 目标 ID。 | +| `binding_id` | 绑定,可为空;规范实体/关系聚合页可只属于知识库。 | +| `snapshot_id` | 来源快照。 | +| `scope` | `binding` / `kb_aggregate`。 | +| `kind` | `raw_source` / `skill_wiki` / `entity_wiki` / `relation_wiki` / `graph_projection`。 | +| `stable_key` | 本地稳定 key。 | +| `remote_kind` | `file` / `manual_document` / `wiki_page`。 | +| `remote_id` | 远端 ID。后端保存完整映射,前端可按权限展示和复制。 | +| `wiki_slug` | Wiki 页面 slug。 | +| `content_hash` | 写入内容 hash。 | +| `version` | 版本号。 | +| `status` | `staged` / `active` / `retired` / `failed` / `delete_failed`。 | +| `created_at` / `updated_at` | 时间戳。 | + +### 16.6 `skill_knowledge_entities` + +规范实体表。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `target_type` | 目标类型。 | +| `target_mode` | 目标模式。 | +| `target_id` | 目标 ID。 | +| `entity_type` | 实体类型。 | +| `normalized_name` | 归一化名称。 | +| `display_name` | 展示名称。 | +| `aliases_json` | 别名。 | +| `description` | 聚合描述。 | +| `stable_slug` | Wiki slug。 | +| `status` | `active` / `retired`。 | +| `created_at` / `updated_at` | 时间戳。 | + +约束: + +```text +unique(target_type, target_id, entity_type, normalized_name) +``` + +### 16.7 `skill_knowledge_entity_contributions` + +规范实体的技能贡献。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `canonical_entity_id` | 规范实体 ID。 | +| `binding_id` | 绑定。 | +| `snapshot_id` | 快照。 | +| `local_entity_id` | IR 中的实体 ID。 | +| `attributes_json` | 该技能贡献的属性。 | +| `evidence_json` | 证据。 | +| `confidence` | 置信度。 | +| `status` | `active` / `retired`。 | +| `created_at` / `updated_at` | 时间戳。 | + +### 16.8 `skill_knowledge_relations` + +规范关系表。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `target_type` | 目标类型。 | +| `target_mode` | 目标模式。 | +| `target_id` | 目标 ID。 | +| `source_entity_id` | 源规范实体。 | +| `predicate` | 规范谓词。 | +| `target_entity_id` | 目标规范实体。 | +| `description` | 聚合说明。 | +| `stable_slug` | Wiki slug。 | +| `status` | `active` / `retired`。 | +| `created_at` / `updated_at` | 时间戳。 | + +约束: + +```text +unique(target_type, target_id, source_entity_id, predicate, target_entity_id) +``` + +### 16.9 `skill_knowledge_relation_contributions` + +规范关系的技能贡献。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `canonical_relation_id` | 规范关系 ID。 | +| `binding_id` | 绑定。 | +| `snapshot_id` | 快照。 | +| `local_relation_id` | IR 中的关系 ID。 | +| `relation_source` | `deterministic` / `explicit_text` / `llm_inferred`。 | +| `evidence_json` | 证据。 | +| `confidence` | 置信度。 | +| `status` | `active` / `retired`。 | +| `created_at` / `updated_at` | 时间戳。 | + +## 17. 后台任务设计 + +参考现有 deep research job 的持久化后台任务模式: + +- 任务入库后返回 `202 Accepted`。 +- 执行器通过 DB 原子条件 claim item。 +- 每个 item 有 `lease_owner`、`lease_until`。 +- 执行中定期 heartbeat。 +- 服务重启后可恢复过期 lease。 +- cancel 使用 `cancel_requested`,任务在阶段边界检查并安全停止。 + +并发建议: + +- 同时处理技能数:2 到 4。 +- 同时调用 WeKnora 写入:2 到 3。 +- 同时 LLM 抽取:按系统现有限流,建议默认 2。 + +200 个技能批量时: + +- 父 job 只保存聚合进度,不串行阻塞 HTTP 请求。 +- 每个技能 item 独立失败、独立重试。 +- 前端按 job polling,不为每个技能单独发 N 个请求。 +- 获取技能列表时批量查 binding summary,避免 N+1。 +- 快照缓存可复用,跨知识库只重新投影,不重复解析同一技能内容。 + +## 18. 后端 API 设计 + +新增 router: + +```text +/api/skill-knowledge +``` + +所有写接口仅管理员可用。 + +### 18.1 获取技能绑定摘要 + +```http +GET /api/skill-knowledge/bindings?skill_names=a,b,c +``` + +返回: + +```json +{ + "items": [ + { + "skill_name": "example-skill", + "knowledge_bases": [ + { + "binding_id": "bind_001", + "target_type": "weknora", + "target_mode": "wiki", + "target_id": "local-kb-id", + "knowledge_base_name": "作战知识库", + "remote_business_key": "weknora:kb:remote-kb-id", + "status": "synced", + "last_synced_at": "2026-08-31T10:00:00Z", + "source_digest": "sha256:...", + "synced_digest": "sha256:...", + "stale": false + } + ] + } + ] +} +``` + +### 18.2 创建归纳任务 + +```http +POST /api/skill-knowledge/sync-jobs +``` + +请求: + +```json +{ + "skill_names": ["skill-a", "skill-b"], + "targets": [ + { + "target_type": "weknora", + "target_mode": "wiki", + "target_id": "local-kb-id" + }, + { + "target_type": "assistant", + "target_mode": "wiki", + "target_id": "assistant-global-base" + } + ], + "force": false +} +``` + +行为: + +- 校验管理员。 +- 校验技能存在。 +- 校验目标知识库存在,并解析目标 adapter。 +- 对只读目标创建 sidecar binding,不强行写远端。 +- 对每个 `skill x target` upsert binding。 +- 创建父 job 和 item。 +- 如果同一 binding 已有 active item,返回当前 item,不重复创建。 +- 返回 `202`。 + +返回: + +```json +{ + "job_id": "job_001", + "status": "queued", + "skill_count": 2, + "target_count": 2, + "total_count": 4, + "items": [ + { + "item_id": "item_001", + "skill_name": "skill-a", + "target_type": "weknora", + "target_mode": "wiki", + "target_id": "local-kb-id", + "binding_id": "bind_001", + "status": "queued" + } + ] +} +``` + +### 18.3 查询任务 + +```http +GET /api/skill-knowledge/sync-jobs/{job_id} +``` + +返回父 job、item 列表、每个 item 当前阶段、错误摘要、绑定结果。 + +### 18.4 取消任务 + +```http +POST /api/skill-knowledge/sync-jobs/{job_id}/cancel +``` + +设置 `cancel_requested = true`。已激活成功的 item 不回滚;未完成 item 在阶段边界停止。 + +### 18.5 重试 item + +```http +POST /api/skill-knowledge/sync-items/{item_id}/retry +``` + +只允许失败或部分失败 item。重试复用原 binding。 + +### 18.6 对单个绑定重新归纳 + +```http +POST /api/skill-knowledge/bindings/{binding_id}/resync +``` + +请求: + +```json +{ + "force": true +} +``` + +行为:创建一个只包含该 binding 的新 job。 + +### 18.7 解除关联 + +建议使用显式 POST,避免 DELETE body 兼容性问题: + +```http +POST /api/skill-knowledge/bindings/{binding_id}/detach +``` + +请求: + +```json +{ + "remote_content_action": "keep" +} +``` + +或: + +```json +{ + "remote_content_action": "delete" +} +``` + +返回解除结果、清理状态和仍被共享的实体/关系数量。 + +### 18.8 知识库反查技能 + +```http +GET /api/skill-knowledge/knowledge-bases/{knowledge_base_id}/skills +``` + +用于知识库详情页展示“该知识库由哪些技能归纳而来”。 + +### 18.9 可选:源状态扫描 + +```http +POST /api/skill-knowledge/source-status +``` + +请求: + +```json +{ + "skill_names": ["skill-a", "skill-b"], + "knowledge_base_id": "local-kb-id" +} +``` + +只做本地快速 fingerprint 比对,标记是否可能 stale,不写 WeKnora。 + +### 18.10 审核队列 + +```http +GET /api/skill-knowledge/reviews?job_id=xxx +POST /api/skill-knowledge/reviews/{review_id}/approve +POST /api/skill-knowledge/reviews/{review_id}/reject +PUT /api/skill-knowledge/reviews/{review_id} +POST /api/skill-knowledge/reviews/bulk-approve +POST /api/skill-knowledge/reviews/bulk-reject +``` + +审核通过后 item 才能进入写入阶段。审核模式为 `off` 时,接口仍保留预览和审计能力。 + +### 18.11 自动同步配置 + +```http +GET /api/skill-knowledge/auto-sync-settings +PUT /api/skill-knowledge/auto-sync-settings +POST /api/skill-knowledge/bindings/{binding_id}/enable-auto-sync +POST /api/skill-knowledge/bindings/{binding_id}/disable-auto-sync +``` + +自动同步设置只允许管理员修改。 + +## 19. 前端交互设计 + +### 19.1 技能管理页 + +在 `AdminSkillsPage.tsx` 的“技能列表”页签增加: + +- 技能行 checkbox。 +- 顶部批量操作按钮:“归纳到知识库”。 +- 支持全选当前页、清空选择。 +- 选择状态跨前端分页保留。 +- 技能较多时保持现有搜索/过滤能力。 + +点击“归纳到知识库”打开弹窗: + +- 显示已选技能数量。 +- 多选目标知识库。 +- 按类型分组展示:WeKnora Wiki、WeKnora 非 Wiki、FAQ、系统会话存档、只读 sidecar、助手知识库。 +- 只读目标要展示“本地归纳,暂不写远端”的状态说明。 +- 助手知识库作为目标时,提示“直接写入知识梳理底库,不经过 WeKnora”。 +- 二次确认文案说明:“将生成原始资料、Wiki 页面和关系内容;抽取完成后进入实体/关系预览审核。” + +提交后: + +- 创建后台 job。 +- 跳转或打开“归纳任务”抽屉/页签。 +- 展示总进度、成功、失败、部分失败。 +- 任务运行中每 2-3 秒 polling。 +- item 到达 `awaiting_review` 时,显示“待审核”并提供审核入口。 + +### 19.1.1 实体/关系审核页 + +审核页按 job 聚合展示: + +- 左侧筛选:技能、目标知识库、类型、置信度、来源。 +- 中间列表:实体、关系、实体对齐候选、关系合并候选、Wiki 草稿。 +- 右侧详情:原始值、模型解释、证据、目标写入位置。 + +管理员可以: + +- 单条批准、拒绝、编辑。 +- 批量批准高置信结果。 +- 批量拒绝低置信结果。 +- 修改实体类型、名称、别名。 +- 修改关系 predicate、说明、confidence。 +- 查看远端 ID 或 sidecar ID。 + +审核完成后,任务继续写入目标知识库。 + +### 19.2 技能行提示 + +`SkillRow` 增加归纳状态区域: + +- 未归纳:不显示或显示弱提示“未归纳”。 +- 已归纳:显示 `已归纳至 N 个知识库`。 +- 展开或悬浮可看到知识库 chips: + - 知识库名称 + - 状态 + - 最近归纳时间 + - stale 标识 + +每个绑定提供动作: + +- 查看知识库。 +- 重新归纳。 +- 解除关联。 +- 失败时重试。 + +状态建议: + +| 状态 | UI 文案 | +| --- | --- | +| `synced` | 已归纳 | +| `stale` | 待重新归纳 | +| `running` | 归纳中 | +| `partial_failed` | 部分完成 | +| `failed` | 失败 | +| `detached_keep` | 已解除,内容保留 | + +### 19.3 解除关联弹窗 + +弹窗必须让管理员明确选择: + +- 保留知识库内容。 +- 删除该技能贡献的内容。 + +弹窗文案应提醒: + +- 删除只删除该技能贡献,不删除其他技能共享的实体与关系。 +- 若删除远端内容失败,可稍后重试清理。 + +### 19.4 归纳任务页签 + +建议在管理员技能管理页新增第三个页签: + +```text +技能列表 | 技能地址管理 | 归纳任务 +``` + +任务列表显示: + +- 创建时间。 +- 目标知识库。 +- 操作人。 +- 总技能数。 +- 成功 / 部分失败 / 失败。 +- 状态。 +- 操作:查看详情、取消、重试失败项。 + +详情显示 item: + +- 技能名。 +- 目标知识库。 +- 目标类型。 +- 当前阶段。 +- 进度。 +- 错误摘要。 +- 绑定状态。 + +### 19.6 自动同步设置 + +管理员可在技能归纳设置中配置: + +- 是否开启文件监听。 +- 是否开启定时同步。 +- 同步周期。 +- 自动写远端开关。 +- 审核模式。 +- 单个 binding 是否参与自动同步。 + +### 19.5 知识库详情反向展示 + +如果现有 WeKnora / LLM Wiki 页面有知识库详情入口,增加“来源技能”区域: + +- 技能名。 +- 归纳状态。 +- 最近归纳时间。 +- 查看技能。 +- 重新归纳。 + +## 20. stale 检测与自动同步 + +系统既要支持手动重新归纳,也要支持自动同步。stale 检测负责判断“源知识文件是否变化”;自动同步负责在配置允许时创建后台任务并远程写入。 + +本地标识 stale: + +- 技能编辑、启停、删除等已知写操作完成后,查找相关 binding 并标记 `stale`。 +- 技能列表加载时可做轻量 fingerprint:路径、大小、mtime。 +- 真正归纳时计算完整 SHA digest。 +- 如果归纳过程中源文件变化,最终激活前再次校验 expected digest;不一致则失败并标记 stale,避免一个版本混入两次源内容。 +- 代码文件和脚本文件不参与 fingerprint,也不触发 stale。 + +自动同步: + +- 当 `watch_enabled` 开启时,知识文件变化后自动创建相关 binding 的归纳任务。 +- 当 `schedule_enabled` 开启时,scheduler 按周期扫描并归纳 stale binding。 +- 当技能保存、导入、删除或启停时,可触发相关 binding 的自动任务。 +- 自动任务必须遵循审核模式;如果需要审核,任务停在 `awaiting_review`。 +- 自动任务必须遵循 staged 激活;失败不覆盖旧版本。 + +## 21. 权限与安全 + +权限: + +- 所有归纳、重归纳、解除关联、取消、重试接口仅管理员。 +- 读接口如果包含后台任务错误或源文件路径,也建议仅管理员。 + +文件安全: + +- 技能根必须限制在 `skills/public` 或 `skills/custom`。 +- 禁止路径穿越。 +- 默认不跟随 symlink;如果要支持 symlink,必须确认 resolved path 仍在技能根内。 +- 压缩包解压必须限制总大小、文件数量、单文件大小、目录层级。 +- 不执行技能代码。 +- 不读取或上传密钥文件。 +- 日志和错误信息不得包含文件全文、token、secret。 + +远端安全: + +- 不新增外部 URL 抓取能力。 +- 如果技能内容中包含 URL,只作为文本关系或知识内容处理,不主动访问。 +- WeKnora 调用失败时,记录错误摘要和 request id,不记录认证头。 + +## 22. LLM 抽取设计 + +抽取可分两级: + +- 小技能:一次性把文本摘要和结构信息送入模型。 +- 大技能:先按文件或章节生成局部摘要与候选实体关系,再合并。 + +提示词需要明确: + +- 只基于提供的内容抽取,不凭空编造。 +- 允许推断,但推断必须有 evidence。 +- 输出必须符合 JSON schema。 +- 不输出敏感信息。 +- 关系必须有 confidence。 +- 低置信关系要标记,不进入规范关系。 + +建议 schema: + +```json +{ + "summary": { + "purpose": "技能用途", + "inputs": ["输入"], + "outputs": ["输出"], + "usage": "使用方式", + "risks": ["风险"] + }, + "entities": [ + { + "id": "ent_001", + "type": "skill|tool|api|file|config|dataset|concept|workflow|external_system|output", + "name": "实体名", + "aliases": [], + "description": "描述", + "attributes": {}, + "evidence": [ + { + "file_path": "SKILL.md", + "line_start": 1, + "line_end": 3, + "quote": "短证据" + } + ], + "confidence": 0.9 + } + ], + "relations": [ + { + "id": "rel_001", + "source_entity_id": "ent_001", + "predicate": "uses", + "target_entity_id": "ent_002", + "description": "关系描述", + "relation_source": "explicit_text|llm_inferred", + "evidence": [], + "confidence": 0.86 + } + ] +} +``` + +模型输出后必须做程序校验: + +- JSON schema 校验。 +- entity id 存在性校验。 +- relation source/target 必须存在。 +- evidence path 必须来自本次 manifest。 +- confidence 必须在 0 到 1。 +- 敏感内容二次扫描。 + +## 23. 后端实现步骤 + +建议按以下顺序实现。 + +### 23.1 持久化层 + +新增 `deerflow.persistence.skill_knowledge`: + +- SQL model。 +- memory store,用于测试或无 DB 环境。 +- repository API。 +- migration 或 `create_all` 接入。 + +Repository API 建议: + +```python +class SkillKnowledgeStore: + async def upsert_binding(...) + async def list_bindings(...) + async def create_job(...) + async def create_items(...) + async def claim_next_item(...) + async def heartbeat_item(...) + async def complete_item(...) + async def fail_item(...) + async def create_or_get_snapshot(...) + async def stage_artifacts(...) + async def activate_snapshot(...) + async def detach_binding(...) +``` + +### 23.2 扫描与解析 + +新增: + +```text +deerflow/skill_knowledge/scanner.py +deerflow/skill_knowledge/parsers.py +deerflow/skill_knowledge/safety.py +deerflow/skill_knowledge/digest.py +``` + +输出 manifest 和初始文本块。 + +### 23.3 抽取与归纳 + +新增: + +```text +deerflow/skill_knowledge/extractor.py +deerflow/skill_knowledge/wiki_builder.py +``` + +职责: + +- 构造 prompt。 +- 调用项目已有模型封装。 +- 解析 JSON。 +- 校验 schema。 +- 构建技能 Wiki 页面内容。 + +### 23.4 实体关系合并 + +新增: + +```text +deerflow/skill_knowledge/merge.py +``` + +职责: + +- 名称归一化。 +- 实体 upsert。 +- 关系 upsert。 +- 贡献增删。 +- 共享实体/关系重建计划。 + +### 23.5 WeKnora 投影 + +新增: + +```text +deerflow/skill_knowledge/projector.py +deerflow/skill_knowledge/target_adapters.py +deerflow/skill_knowledge/graph_writer.py +``` + +职责: + +- 根据 IR 和聚合结果生成 artifact plan。 +- 按目标知识库类型选择 adapter。 +- 调用 WeKnora client 上传原文、FAQ、会话归档或 Wiki 页面。 +- 为只读目标写本地 sidecar artifact。 +- 上传 graph projection docs。 +- 在配置允许时通过受控 graph writer 写 Neo4j。 +- 校验远端状态。 + +### 23.6 Gateway + +新增: + +```text +app/gateway/routers/skill_knowledge.py +app/gateway/skill_knowledge_job_dispatcher.py +app/gateway/skill_knowledge_job_executor.py +app/gateway/skill_knowledge_watcher.py +app/gateway/skill_knowledge_scheduler.py +``` + +在 app 初始化中注册: + +- store。 +- dispatcher。 +- watcher。 +- scheduler。 +- router。 + +### 23.7 前端 + +新增: + +```text +frontend-web/src/strategy-components/api/skill-knowledge.ts +frontend-web/src/components/workspace/skills/skill-knowledge-sync-dialog.tsx +frontend-web/src/components/workspace/skills/skill-knowledge-bindings.tsx +frontend-web/src/components/workspace/skills/skill-knowledge-jobs-panel.tsx +``` + +修改: + +- `AdminSkillsPage.tsx`:技能列表批量选择、归纳弹窗、归纳任务页签。 +- `skill-settings-page.tsx`:`SkillRow` 展示归纳知识库 chips 和操作。 +- 知识库详情页:展示来源技能。 + +### 23.8 助手知识库 + +新增: + +```text +offline-backend-20260512/backend/packages/harness/deerflow/assistant_knowledge/ +offline-backend-20260512/backend/packages/harness/deerflow/persistence/assistant_knowledge/ +offline-backend-20260512/backend/app/gateway/routers/assistant_knowledge.py +offline-backend-20260512/backend/app/gateway/assistant_knowledge_import_executor.py +frontend-web/src/strategy-components/api/assistant-knowledge.ts +frontend-web/src/pages/AssistantKnowledgePage.tsx +``` + +职责: + +- 初始化唯一全局助手知识库“知识梳理”。 +- 从 WeKnora 导出包导入 Wiki、文档、chunk、实体、关系、图谱。 +- 做全局实体对齐和关系合并。 +- 生成助手库 Wiki 页面。 +- 建立搜索索引。 +- 支持普通用户只读、管理员导入和维护。 +- 支持知识库产品版本 `v1` / `v2` 切换。 + +### 23.9 审核队列 + +新增: + +```text +offline-backend-20260512/backend/packages/harness/deerflow/persistence/skill_knowledge/review_model.py +frontend-web/src/components/workspace/skills/skill-knowledge-review-panel.tsx +``` + +职责: + +- 保存实体、关系、对齐候选、Wiki 草稿的审核项。 +- 支持批准、拒绝、编辑、批量处理。 +- 审核完成后唤醒对应 item 继续写入。 +- 保留完整审计。 + +## 24. 测试计划 + +后端测试: + +| 测试 | 覆盖点 | +| --- | --- | +| `test_skill_knowledge_scanner.py` | 文件扫描、排除目录、敏感文件阻断、压缩包安全。 | +| `test_skill_knowledge_extractor.py` | LLM 输出 schema 校验、低置信关系过滤、证据路径校验。 | +| `test_skill_knowledge_entity_merge.py` | 同知识库实体合并、跨知识库不合并、属性冲突 provenance。 | +| `test_skill_knowledge_repository.py` | 绑定唯一、多对多、状态机、detach keep/delete。 | +| `test_skill_knowledge_jobs.py` | job/item claim、lease、重试、取消、崩溃恢复、200 item 聚合进度。 | +| `test_skill_knowledge_weknora_projection.py` | Wiki slug upsert、artifact staged/active、GraphRAG 不可用 partial_failed。 | +| `test_skill_knowledge_api.py` | 管理员鉴权、目标知识库校验、创建任务、查询任务、解除关联。 | +| `test_skill_knowledge_target_adapters.py` | Wiki、非 Wiki、FAQ、会话存档、只读 sidecar、助手库目标写入。 | +| `test_skill_knowledge_review.py` | 实体/关系逐条审核、编辑、批量批准、审核阻塞写入。 | +| `test_skill_knowledge_auto_sync.py` | 文件监听、定时同步、代码文件变化不触发、staged 自动写入。 | +| `test_skill_knowledge_graph_writer.py` | Neo4j 参数化写入、事务、审计、回滚计划、禁用任意 Cypher。 | +| `test_assistant_knowledge_repository.py` | 唯一全局底库、来源、导入任务、实体关系贡献。 | +| `test_assistant_knowledge_import.py` | WeKnora 导出包导入、实体对齐、关系合并、搜索索引。 | +| `test_assistant_knowledge_api.py` | 普通用户只读、管理员写入、未配置 WeKnora 场景。 | + +前端测试: + +- 管理员技能页可以多选技能并打开知识库选择弹窗。 +- 知识库选择弹窗支持多选目标,包含 Wiki、非 Wiki、FAQ、会话存档、只读 sidecar 和助手知识库。 +- 创建 job 后显示进度并 polling。 +- 抽取后进入实体/关系审核页,审核通过后继续写入。 +- 技能行展示已归纳知识库 chips。 +- 重归纳按钮调用正确接口。 +- 解除关联弹窗必须选择 keep/delete。 +- 200 个技能绑定摘要使用批量接口,不触发 N+1。 +- 未配置 WeKnora 时仍能展示助手知识库初始化入口。 +- 普通知识库详情页可触发导入到助手知识库。 +- 助手知识库搜索、Wiki 页面、来源列表按权限展示。 + +建议命令: + +```bash +cd offline-backend-20260512/backend +PYTHONPATH=. uv run pytest tests/test_skill_knowledge_*.py -v +PYTHONPATH=. uv run pytest tests/test_harness_boundary.py -v +``` + +```bash +cd frontend-web +pnpm typecheck +pnpm test -- skill-knowledge +``` + +## 25. 验收场景 + +必须通过: + +- 管理员选择 3 个技能归纳到 2 个目标知识库,生成 1 个 job、6 个 item、6 个 binding。 +- 同一个技能随后归纳到另一个知识库,新增第二个 binding,技能行显示已归纳至 2 个知识库。 +- 技能可以直接归纳到助手知识库,并生成实体对齐、Wiki 页面版本和搜索索引。 +- 同一个技能同一个 digest 重复归纳,不产生重复 Wiki 页面或重复实体关系。 +- 两个技能在同一知识库中抽取出同名同类型实体,只生成一个规范实体页,并列出两个贡献技能。 +- 解除其中一个技能并选择删除内容后,共享实体仍保留另一个技能贡献。 +- 关系必须带 evidence 和 confidence;低于 0.75 的推断关系不进入规范关系。 +- 重归纳成功后,新版本替换旧版本;旧 artifact 进入 retired。 +- 重归纳失败时,旧版本继续显示为当前版本。 +- 解除关联选择保留内容后,远端 Wiki 和文档保留,但 binding 不再参与后续重归纳。 +- 非管理员访问写接口返回 403。 +- 非 Wiki、FAQ、系统会话存档知识库可作为目标,并通过对应 adapter 写入。 +- 只读知识库可作为目标,结果写入本地 sidecar,远端状态显示 `pending_remote_write`。 +- 直接 Neo4j 写入只在配置开启时执行,且只使用参数化 graph writer。 +- 自动文件监听和定时同步可以创建后台任务,代码文件变化不会触发同步。 +- 前端可逐条审核实体/关系,也可按策略批量处理。 +- 前端展示 WeKnora 远端 ID,并可复制业务 key。 +- WeKnora GraphRAG 不可用时,原始资料和 Wiki 成功,状态为 `partial_failed`,可重试图谱投影。 +- 200 个技能批量归纳时,页面显示整体进度,失败技能可单独重试,后端没有 N+1 查询。 +- 技能中出现 `.env` 或私钥时,该文件不上传、不进 prompt,页面显示安全跳过。 +- 技能中的 `.py`、`.ts`、`.sh` 等代码文件不读取、不上传、不摘要,代码文件变化不触发知识 stale。 +- 未配置 WeKnora 时,助手知识库仍可初始化和查看。 +- 管理员可从普通知识库触发导入到助手知识库。 +- 管理员可向助手知识库导入本地文件,安全扫描通过后生成 Wiki 页面版本。 +- WeKnora 导出包中的 Wiki、文档、chunk、实体、关系、图谱都能进入助手底库。 +- 助手底库把多个来源中的同一人或同一对象合并为一个实体 Wiki 页。 +- 助手 Wiki 支持历史版本、对比和回滚。 +- 助手知识库普通用户只读,管理员可导入、重导、删除来源。 + +## 26. 分阶段交付 + +第一阶段:数据模型与 API 骨架 + +- 建表和 store。 +- 绑定列表、创建 job、查询 job、detach API。 +- 管理员鉴权。 +- 前端技能行能展示空绑定状态。 + +第二阶段:扫描、快照与后台任务 + +- 技能目录扫描。 +- 文件 digest 和 manifest。 +- job/item 执行器、lease、heartbeat、失败重试。 +- 200 技能批量进度。 + +第三阶段:Wiki 归纳与原始资料上传 + +- LLM 摘要和 Wiki 页面生成。 +- WeKnora 原文上传。 +- Wiki page upsert。 +- staged/active 替换。 + +第四阶段:实体关系合并与关系投影 + +- 规范实体和关系表。 +- contribution 机制。 +- 关系 Wiki 页面。 +- graph projection docs。 +- GraphRAG 能力探测和 partial_failed。 + +第五阶段:完整前端体验 + +- 多选技能归纳弹窗。 +- 归纳任务页签。 +- 技能行 chips、详情、重归纳、解除关联。 +- 知识库详情来源技能反查。 + +第六阶段:安全、测试、文档收口 + +- 敏感文件阻断。 +- 压缩包安全。 +- 崩溃恢复。 +- README / CLAUDE 更新。 +- 后端和前端测试。 + +第七阶段:助手知识库与产品版本 + +- 新增唯一全局助手知识库“知识梳理”。 +- 新增 WeKnora 导出到助手库导入任务。 +- 新增技能直接导入助手知识库。 +- 新增管理员本地文件导入助手知识库。 +- 新增助手库实体对齐、关系合并和 Wiki 展示。 +- 新增助手库搜索。 +- 新增助手库 Wiki 页面版本管理、对比和回滚。 +- 新增知识库产品版本 `v1` / `v2` 切换。 +- 处理未配置 WeKnora 时的助手库默认展示。 + +第八阶段:多目标、审核、自动同步和底层图写入 + +- 支持一次选择多个目标知识库。 +- 支持非 Wiki、FAQ、系统会话存档和只读目标 adapter。 +- 支持实体/关系逐条审核。 +- 支持文件监听、定时同步、技能变更自动远程写入。 +- 支持受控 Neo4j direct writer。 +- 支持远端 WeKnora ID 作为业务 key 暴露给前端。 + +## 27. 给实现大模型的硬性指令 + +实现时必须遵守: + +- 先完整阅读根目录 `AGENTS.md`、后端 `AGENTS.md`、后端 `CLAUDE.md`。 +- 不要回滚用户已有改动。 +- 支持直接写 WeKnora 的 Neo4j,但必须通过受控 graph writer、显式配置、参数化模板、事务和审计。 +- 支持把远端 WeKnora ID 作为前端业务 key 暴露,但本地仍保留 surrogate id。 +- 不要让 `deerflow.*` import `app.*`。 +- 不要执行技能里的代码。 +- 不要读取、上传、摘要或展示技能里的代码文件、脚本文件和代码块内容。 +- 不要上传或提示词输入密钥文件。 +- 技能可以直接归纳到助手知识库,必须走同样的安全扫描、审核和实体对齐。 +- 不要创建多个助手知识库;助手知识库只有一个全局“知识梳理”底库。 +- 自动 watcher、定时同步和技能变更远程写入必须使用 staged 激活、去重、审核和失败保留旧版本。 +- 重归纳必须 staged 写入,成功后再激活。 +- 删除绑定贡献时必须按 contribution refcount 处理共享实体和关系。 +- 助手知识库需要 Wiki 页面版本管理、版本对比、回滚和导入任务审计。 +- 直接写 Neo4j 必须通过受控 graph writer 和参数化模板,不允许执行 LLM 生成的任意 Cypher。 +- 远端 WeKnora ID 可以作为前端业务 key 暴露,但本地仍要保留 surrogate id 和 mapping。 +- 后端代码变更后按仓库要求更新相关 README / CLAUDE。 +- 涉及文件编辑使用 `apply_patch` 或项目正常格式化工具,不做无关重构。 + +## 28. 实现任务清单 + +- [ ] 新增 `skill_knowledge` 持久化模型和 store。 +- [ ] 新增绑定、job、item、artifact、entity、relation、contribution 表。 +- [ ] 新增管理员 router `/api/skill-knowledge`。 +- [ ] 接入知识库能力校验,支持 Wiki、非 Wiki、FAQ、会话存档、只读 sidecar 和助手知识库。 +- [ ] 支持一次选择多个目标知识库,并按 `技能 x 目标` 生成 item。 +- [ ] 新增技能扫描器,支持全部非代码知识文件、安全排除和 digest。 +- [ ] 技能扫描器完全排除代码文件、脚本文件和 Markdown 代码块内容。 +- [ ] 新增 snapshot 缓存。 +- [ ] 新增 LLM 抽取器,输出 schema 校验。 +- [ ] 新增 Wiki 页面生成器。 +- [ ] 新增实体归一化与合并逻辑。 +- [ ] 新增关系归一化与合并逻辑。 +- [ ] 新增 WeKnora artifact projector。 +- [ ] 新增 target adapters。 +- [ ] 新增 graph projection 文档生成和上传。 +- [ ] 新增受控 Neo4j direct graph writer。 +- [ ] 新增实体/关系审核队列。 +- [ ] 新增后台 job dispatcher / executor。 +- [ ] 新增文件 watcher、定时同步 scheduler、技能变更自动写入触发。 +- [ ] 实现 resync 成功替换、失败保留旧版本。 +- [ ] 实现 detach keep/delete。 +- [ ] 技能变更后标记 binding stale。 +- [ ] 前端新增 `skill-knowledge.ts` API。 +- [ ] 技能管理页新增多选归纳入口。 +- [ ] 新增选择知识库弹窗。 +- [ ] 新增归纳任务页签。 +- [ ] 技能行展示已归纳知识库状态。 +- [ ] 实现重归纳、重试、取消、解除关联 UI。 +- [ ] 知识库详情展示来源技能。 +- [ ] 新增唯一全局助手知识库“知识梳理”。 +- [ ] 新增助手知识库持久化模型、来源、导入任务、Wiki、实体、关系、贡献表。 +- [ ] 新增 WeKnora 导出到助手知识库任务。 +- [ ] 支持技能直接导入助手知识库。 +- [ ] 支持管理员本地文件导入助手知识库。 +- [ ] 导出范围覆盖 Wiki、文档、chunk、实体、关系、图谱。 +- [ ] 助手知识库实现全局实体对齐和关系合并。 +- [ ] 助手知识库生成 Wiki 页面并支持搜索。 +- [ ] 助手知识库支持 Wiki 页面版本管理、对比、发布和回滚。 +- [ ] 新增知识库产品版本 `v1` / `v2` 设置,默认 `v1`。 +- [ ] 未配置 WeKnora 时仍展示助手知识库创建或初始化入口。 +- [ ] 助手知识库普通用户只读,管理员可导入、重导、删除。 +- [ ] 后端测试覆盖核心状态机和安全边界。 +- [ ] 前端测试覆盖主要交互。 +- [ ] 更新后端 README / CLAUDE 与必要前端文档。 + +## 29. 风险与处理 + +| 风险 | 处理 | +| --- | --- | +| WeKnora 图谱 API 在部署版本不稳定 | 以 Swagger 能力探测为准;Wiki 关系页和 graph projection 文档作为稳定方案。 | +| LLM 推断关系误判 | 强制 evidence、confidence 阈值、低置信不进入规范关系。 | +| 200 技能批量耗时长 | 后台 job、item 并发、snapshot 缓存、前端批量查询。 | +| 重归纳中断导致新旧混杂 | staged artifact + 本地事务激活 + 失败保留旧版本。 | +| 删除共享实体误删其他技能知识 | contribution refcount,删除只移除该 binding 贡献。 | +| 敏感信息进入知识库 | 文件级安全阻断、prompt 前二次扫描、日志脱敏。 | +| 技能目录处理阻塞 Gateway | 文件 IO 和转换统一 `asyncio.to_thread`。 | +| 技能源文件归纳中变化 | expected digest 校验,不一致则失败并标记 stale。 | + +## 30. 新增:普通知识库与助手知识库双体系 + +本节是基于新需求的补充设计。后续实现以本节为准;如果本节与前文旧的 WeKnora-only 表述冲突,优先采用本节。 + +### 30.1 产品形态 + +系统需要区分两类知识库: + +| 类型 | 存储 | 用途 | 可见性 | 写入来源 | +| --- | --- | --- | --- | --- | +| 普通知识库 | WeKnora | 文档向量化、Wiki、检索、图谱、技能归纳目标 | 按现有 WeKnora 逻辑 | 技能归纳、现有 WeKnora 导入 | +| 助手知识库 | DeerFlow 自研库 | 全局知识梳理、实体对齐、Wiki 展示、搜索、版本管理 | 所有人可见,普通用户只读 | WeKnora 导出、WeKnora 自动沉淀、技能直接归纳、管理员本地文件导入 | + +产品版本开关: + +- `v1`:默认值,优先展示现有普通知识库体验。 +- `v2`:优先展示助手知识库体验。 +- 管理员可在系统设置中切换版本。 +- 如果未配置 WeKnora,即使当前版本默认是 `v1`,页面也应展示助手知识库创建/初始化入口;WeKnora 相关能力隐藏或置灰,并提示“未配置普通知识库服务”。 + +建议新增系统设置项: + +```json +{ + "knowledge_base_product_version": "v1" +} +``` + +### 30.2 助手知识库定位 + +助手知识库只有一个全局底库,建议默认名: + +```text +知识梳理 +``` + +它不是 WeKnora 的另一个知识库,也不向 WeKnora 写数据。它是 DeerFlow 自己维护的结构化知识底库。 + +助手知识库对用户展示: + +- Wiki 页面列表。 +- Wiki 页面详情。 +- Wiki 页面版本、对比和回滚。 +- 实体页。 +- 关系页。 +- 搜索。 +- 数据导入记录。 + +助手知识库内部可以存实体、关系、贡献、来源、搜索索引,但前端主体验以 Wiki 为中心,不做复杂图数据库管理页。 + +权限: + +- 普通用户:可查看、搜索。 +- 管理员:可初始化助手知识库、从 WeKnora 导入、重新导入、删除导入来源、编辑或删除 Wiki 页面。 + +### 30.3 数据来源 + +助手知识库的数据来源包括 WeKnora、技能归纳和管理员本地文件。 + +支持四种触发方式: + +| 方式 | 说明 | +| --- | --- | +| 自动沉淀 | WeKnora 普通知识库完成文档向量化、Wiki 生成和图谱抽取后,默认创建一个导入任务,把处理结果沉淀到助手知识库。 | +| 手动导入 | 管理员在普通知识库页面点击“导入到助手知识库”或“导出到知识梳理”,本质仍是导出 WeKnora 数据后导入助手库。 | +| 技能直接归纳 | 管理员在技能管理页选择助手知识库作为目标,系统直接写入自研助手底库。 | +| 本地文件导入 | 管理员在助手知识库页面上传本地文件,系统安全扫描后抽取实体、关系和 Wiki 页面。 | + +限制: + +- 普通用户不能写入助手知识库。 +- 本地文件导入必须走敏感信息扫描、文件类型白名单、大小限制和导入审计。 +- 技能直接归纳仍然完全排除代码文件、脚本文件和 Markdown 代码块内容。 + +### 30.4 WeKnora 导出范围 + +从 WeKnora 导入助手知识库时,需要尽可能导出全部已处理数据: + +- 知识库元数据。 +- Wiki 页面。 +- Wiki 页面之间的链接。 +- 已上传文档信息。 +- 已向量化完成的 chunk 文本、chunk metadata、来源文档映射。 +- WeKnora 可读取的实体。 +- WeKnora 可读取的关系。 +- WeKnora 可读取的图谱节点和边。 +- 技能归纳产生的 source metadata,例如 `skill_name`、`binding_id`、`snapshot_id`。 + +说明: + +- 助手知识库不强依赖复用 WeKnora embedding 向量。若 WeKnora 不暴露 embedding 原始向量,导入 chunk 文本和 metadata 即可。 +- 如果 WeKnora 不暴露稳定的图谱导出 API,则使用现有 Wiki graph、关系投影文档、Wiki 关系页和可查询接口重建实体关系。 +- 导入过程要记录能力缺失:例如“已导入 Wiki 与文档,图谱 API 不可用,关系由 Wiki 关系页重建”。 + +导出包建议: + +```json +{ + "export_id": "wk_export_001", + "source": { + "type": "weknora", + "knowledge_base_id": "local-kb-id", + "knowledge_base_name": "作战知识库", + "exported_at": "2026-09-01T10:00:00Z" + }, + "wiki_pages": [], + "documents": [], + "chunks": [], + "entities": [], + "relations": [], + "graph": { + "nodes": [], + "edges": [] + } +} +``` + +### 30.5 助手库实体对齐 + +助手知识库的核心价值是把来自不同 WeKnora 普通知识库、不同技能来源、不同 Wiki 页中的同一实体合并。 + +实体对齐范围是全局助手库: + +```text +assistant_base_id + entity_type + normalized_name +``` + +如果存在更强 ID,则优先: + +```text +assistant_base_id + entity_type + external_id_type + external_id +``` + +对齐规则: + +- 确定性同一:证件号、统一社会信用代码、装备编号、系统唯一 ID 等完全一致,直接合并。 +- 高置信名称同一:同类型实体 normalized name 一致,合并。 +- 模型推断同一:允许模型判断“张三”“张三同志”“Zhang San”是否同一人,但必须给出 evidence 和 confidence。 +- `confidence >= 0.90` 的模型对齐可以自动合并。 +- `0.70 <= confidence < 0.90` 的模型对齐不自动合并,可记录为候选别名或候选关联。 +- `confidence < 0.70` 丢弃。 + +合并后必须保留来源贡献: + +- 来自哪个 WeKnora 知识库。 +- 来自哪个 Wiki 页面或 chunk。 +- 来自哪个技能 binding。 +- 证据文本。 +- 导入任务 ID。 + +属性冲突不覆盖: + +- 不同来源给出不同姓名、职务、时间、地点、数值时,按来源分组展示。 +- Wiki 页面中展示“多来源信息”而不是最后写入覆盖。 + +### 30.6 助手库关系对齐 + +关系 key: + +```text +assistant_base_id + source_assistant_entity_id + normalized_predicate + target_assistant_entity_id +``` + +关系来源: + +- WeKnora 图谱边。 +- WeKnora Wiki graph 链接。 +- 技能归纳时写入 WeKnora 的关系页。 +- graph projection 文档。 +- 模型从 WeKnora 导出包中推断的高置信关系。 + +关系合并后保留多个证据贡献。 + +关系 Wiki 页面建议: + +```text +assistant/relations/{predicate}/{stable_relation_id} +``` + +关系页内容: + +- 源实体。 +- 关系类型。 +- 目标实体。 +- 关系描述。 +- 来源知识库。 +- 来源 Wiki 页。 +- 证据和置信度。 + +### 30.7 助手库 Wiki 生成 + +助手库最终以 Wiki 页面展示。 + +页面类型: + +| 页面 | slug 示例 | 说明 | +| --- | --- | --- | +| 首页 | `assistant/home` | 知识梳理总览、实体数量、来源数量、最近导入。 | +| 来源页 | `assistant/sources/{source_id}` | 某个 WeKnora 知识库导入结果。 | +| 实体页 | `assistant/entities/{type}/{entity_id}-{name}` | 对齐后的全局实体页面。 | +| 关系页 | `assistant/relations/{predicate}/{relation_id}` | 对齐后的全局关系页面。 | +| 主题页 | `assistant/topics/{topic}` | 从 WeKnora Wiki 分类或目录生成。 | + +实体页结构: + +- 标题:实体名。 +- 类型:人物、组织、地点、装备、事件、概念、数据对象等。 +- 摘要:模型基于多来源融合生成。 +- 别名。 +- 多来源属性。 +- 相关实体。 +- 相关关系。 +- 来源 Wiki 页面。 +- 证据。 + +助手库 Wiki 页面需要版本管理。每次 WeKnora 导入、技能直接归纳、管理员本地文件导入、实体对齐重建或人工编辑,都生成新 revision。新 revision 先处于 draft 或 staged 状态,发布成功后成为 current。 + +版本能力: + +- 查看历史版本。 +- 对比两个版本。 +- 回滚到历史版本。 +- 查看版本来源:导入任务、技能归纳任务、人工编辑。 +- 查看版本发布人和发布时间。 + +### 30.8 助手库搜索 + +助手知识库需要支持搜索。 + +V1 建议实现: + +- Wiki 标题搜索。 +- 实体名称和别名搜索。 +- Wiki 正文全文搜索。 +- 来源知识库过滤。 +- 实体类型过滤。 + +实现选择: + +- SQLite 可使用 FTS5。 +- PostgreSQL 可使用 tsvector 或 trigram。 +- 如果现有持久化层不方便,先实现数据库 LIKE + normalized name 索引,后续再升级全文索引。 + +搜索结果只展示 Wiki 页面,不直接展示底层 chunk。 + +### 30.9 助手库持久化表 + +建议新增: + +```text +deerflow.persistence.assistant_knowledge +``` + +核心表: + +#### `assistant_knowledge_base` + +只允许一条 active 记录。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `name` | 默认“知识梳理”。 | +| `status` | `active` / `disabled`。 | +| `created_by` | 初始化管理员。 | +| `created_at` / `updated_at` | 时间戳。 | + +#### `assistant_knowledge_sources` + +记录每个来源。来源可以是 WeKnora 知识库、技能归纳或管理员本地导入。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `source_type` | `weknora` / `skill` / `local_file`。 | +| `knowledge_base_mapping_id` | DeerFlow 本地 WeKnora 映射 ID,可为空。 | +| `skill_name` | 技能名,可为空。 | +| `upload_batch_id` | 本地文件导入批次,可为空。 | +| `source_name` | 来源名称。 | +| `last_import_job_id` | 最近导入任务。 | +| `status` | `active` / `import_failed` / `deleted`。 | +| `created_at` / `updated_at` | 时间戳。 | + +#### `assistant_knowledge_import_jobs` + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `source_id` | 来源 ID。 | +| `trigger` | `auto_after_weknora_indexed` / `manual_from_weknora` / `skill_direct` / `manual_file_upload` / `scheduled` / `watcher`。 | +| `status` | `queued` / `running` / `completed` / `partial_failed` / `failed`。 | +| `phase` | `exporting` / `scanning` / `parsing` / `awaiting_review` / `aligning_entities` / `aligning_relations` / `writing_wiki` / `indexing_search` / `completed`。 | +| `counts_json` | 导入页面、实体、关系、chunk 数。 | +| `last_error_message` | 错误摘要。 | +| `created_by` | 操作人;自动任务可为空或 system。 | +| `created_at` / `completed_at` | 时间戳。 | + +#### `assistant_knowledge_wiki_pages` + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `slug` | 全局唯一 slug。 | +| `title` | 标题。 | +| `page_type` | `home` / `source` / `entity` / `relation` / `topic`。 | +| `current_revision_id` | 当前发布版本。 | +| `content_markdown` | 当前版本冗余内容,便于列表和搜索。 | +| `summary` | 摘要。 | +| `status` | `active` / `deleted`。 | +| `search_text` | 搜索文本。 | +| `updated_at` | 更新时间。 | + +#### `assistant_knowledge_wiki_revisions` + +保存 Wiki 页面版本。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `page_id` | Wiki 页面。 | +| `revision_no` | 递增版本号。 | +| `content_markdown` | 版本内容。 | +| `summary` | 版本摘要。 | +| `source_id` | 来源,可为空。 | +| `import_job_id` | 导入任务,可为空。 | +| `change_type` | `import` / `skill_sync` / `manual_edit` / `entity_rebuild` / `rollback`。 | +| `status` | `draft` / `staged` / `published` / `superseded`。 | +| `created_by` | 创建人。 | +| `created_at` / `published_at` | 时间戳。 | + +#### `assistant_knowledge_entities` + +保存全局对齐后的实体。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `entity_type` | 实体类型。 | +| `normalized_name` | 归一化名称。 | +| `display_name` | 展示名称。 | +| `aliases_json` | 别名。 | +| `attributes_json` | 聚合属性。 | +| `wiki_page_id` | 实体 Wiki 页。 | +| `status` | `active` / `deleted`。 | + +#### `assistant_knowledge_entity_contributions` + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `entity_id` | 全局实体。 | +| `source_id` | WeKnora 来源。 | +| `import_job_id` | 导入任务。 | +| `source_wiki_slug` | 来源 Wiki 页。 | +| `source_chunk_id` | 来源 chunk,可为空。 | +| `source_skill_name` | 来源技能,可为空。 | +| `evidence_json` | 证据。 | +| `confidence` | 置信度。 | + +#### `assistant_knowledge_relations` + +保存全局对齐后的关系。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `source_entity_id` | 源实体。 | +| `predicate` | 关系类型。 | +| `target_entity_id` | 目标实体。 | +| `description` | 聚合描述。 | +| `wiki_page_id` | 关系 Wiki 页。 | +| `status` | `active` / `deleted`。 | + +#### `assistant_knowledge_relation_contributions` + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `relation_id` | 全局关系。 | +| `source_id` | WeKnora 来源。 | +| `import_job_id` | 导入任务。 | +| `source_wiki_slug` | 来源 Wiki 页。 | +| `graph_edge_id` | WeKnora 图谱边 ID,可为空。 | +| `evidence_json` | 证据。 | +| `confidence` | 置信度。 | + +### 30.10 助手库 API + +新增 router: + +```text +/api/assistant-knowledge +``` + +建议接口: + +```http +GET /api/assistant-knowledge/base +POST /api/assistant-knowledge/base/initialize +GET /api/assistant-knowledge/wiki-pages +GET /api/assistant-knowledge/wiki-pages/{slug} +GET /api/assistant-knowledge/wiki-pages/{slug}/revisions +GET /api/assistant-knowledge/wiki-pages/{slug}/revisions/{revision_id} +POST /api/assistant-knowledge/wiki-pages/{slug}/revisions/{revision_id}/rollback +GET /api/assistant-knowledge/search?q=xxx&type=entity&source_id=xxx +GET /api/assistant-knowledge/entities/{id} +GET /api/assistant-knowledge/relations/{id} +GET /api/assistant-knowledge/sources +GET /api/assistant-knowledge/import-jobs +GET /api/assistant-knowledge/import-jobs/{id} +POST /api/assistant-knowledge/sources/{source_id}/reimport +POST /api/assistant-knowledge/imports/files +POST /api/assistant-knowledge/imports/skills +DELETE /api/assistant-knowledge/sources/{source_id} +``` + +新增 WeKnora 普通知识库导出或导入触发接口: + +```http +POST /api/llmwiki/knowledge-bases/{knowledge_base_id}/export-to-assistant +``` + +行为: + +- 校验管理员。 +- 校验助手知识库已初始化;未初始化则自动创建“知识梳理”或提示管理员确认创建。 +- 校验 WeKnora 知识库已完成处理。 +- 创建 `assistant_knowledge_import_jobs`。 +- 后台导出 WeKnora 数据并导入助手库。 + +本地文件导入接口: + +```http +POST /api/assistant-knowledge/imports/files +``` + +使用 multipart 上传文件。后端必须先完成安全扫描和敏感信息阻断,再进入解析、抽取、审核、实体对齐和 Wiki 版本生成。 + +技能直接导入接口: + +```http +POST /api/assistant-knowledge/imports/skills +``` + +请求: + +```json +{ + "skill_names": ["skill-a", "skill-b"], + "force": false +} +``` + +该接口也可以由 `/api/skill-knowledge/sync-jobs` 在目标类型为 `assistant` 时内部复用。 + +系统设置接口需要支持: + +```http +GET /api/system-settings/knowledge-base-product-version +PUT /api/system-settings/knowledge-base-product-version +``` + +### 30.11 前端页面 + +知识库入口需要支持产品版本切换。 + +建议: + +- 管理员设置页提供“知识库版本”切换:`v1 普通知识库` / `v2 助手知识库`,默认 `v1`。 +- 知识库页面顶部用 Tabs 或 Segmented Control 区分“普通知识库”和“助手知识库”。 +- 未配置 WeKnora 时,普通知识库 tab 隐藏或禁用;助手知识库 tab 正常显示。 +- 助手知识库未初始化时,展示“创建知识梳理库”按钮。 +- 创建后展示 Wiki 首页、搜索框、来源列表、最近导入和页面版本入口。 +- 普通知识库详情页增加按钮:“导入到助手知识库”。 +- 普通知识库列表中可显示“已沉淀到知识梳理 / 待导入 / 导入失败”状态。 +- 助手知识库页面为管理员提供“导入本地文件”和“导入技能”入口。 + +助手知识库页面建议: + +```text +知识梳理 +├── 搜索 +├── Wiki 首页 +├── 实体 +├── 关系 +├── 来源知识库 +├── 导入记录 +└── 页面版本 +``` + +普通用户只看到搜索、Wiki、实体、关系、来源和只读版本历史。管理员额外看到导入、重新导入、删除来源、版本对比和回滚。 + +### 30.12 自动沉淀时机 + +WeKnora 普通知识库完成以下任意流程后,应触发或排队助手库导入: + +- 管理员从技能归纳到 WeKnora 成功。 +- WeKnora 文档上传并完成向量化。 +- WeKnora Wiki 页面生成或更新完成。 +- WeKnora 图谱抽取完成。 +- 管理员手动点击“导入到助手知识库”。 + +为了避免频繁重复导入: + +- 对每个 WeKnora 知识库维护 `last_export_digest`。 +- 导出前计算 Wiki 页面、文档 chunk、图谱摘要的 digest。 +- digest 未变化时跳过导入。 +- 同一 WeKnora 知识库同一时间只允许一个 active 导入任务。 + +### 30.13 与技能归纳的关系 + +技能归纳支持两条链路。 + +链路 A:先进入 WeKnora,再沉淀助手库。 + +```text +技能知识文件 + -> WeKnora 普通知识库 + -> WeKnora 向量化 / Wiki / 图谱 + -> 导出已处理数据 + -> 助手知识库“知识梳理” + -> 全局实体对齐 + -> Wiki 展示与搜索 +``` + +链路 B:直接进入助手知识库。 + +```text +技能知识文件 + -> 助手知识库“知识梳理” + -> 实体/关系预览审核 + -> 全局实体对齐 + -> Wiki 页面版本发布 + -> 搜索索引 +``` + +代码文件在第一步就被排除,不进入后续任何知识库。 + +### 30.14 新增测试与验收 + +新增后端测试: + +- 未配置 WeKnora 时,助手知识库 base 接口可用,普通知识库导入接口返回明确不可用。 +- 初始化助手知识库后,只存在一个 active base。 +- 普通用户可读助手 Wiki,写接口返回 403。 +- 管理员可从 WeKnora 知识库触发导入任务。 +- 管理员可把技能直接导入助手知识库。 +- 管理员可上传本地文件导入助手知识库。 +- WeKnora 导出包包含 Wiki、文档、chunk、实体、关系、图谱时,助手库全部导入。 +- 同一个人或同一对象来自多个 WeKnora 来源时,在助手库合并为一个实体。 +- 模型推断实体对齐 `confidence >= 0.90` 自动合并,低于阈值不合并。 +- 删除某个来源时,只删除该来源贡献;其他来源贡献仍保留。 +- 助手库搜索能命中 Wiki 标题、实体别名和正文。 +- 助手 Wiki 页面支持 revision、对比和回滚。 +- 技能归纳扫描不读取、不上传、不摘要代码文件,代码文件变化不触发 stale。 + +新增前端测试: + +- 知识库产品版本默认 `v1`。 +- 管理员可切换 `v1` / `v2`。 +- 未配置 WeKnora 时仍可看到“创建知识梳理库”。 +- 普通知识库详情页有“导入到助手知识库”按钮。 +- 助手知识库页面展示 Wiki、搜索、来源、导入记录。 +- 助手知识库页面展示页面版本、对比和回滚入口。 +- 普通用户看不到导入、删除、重新导入和回滚动作。 diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index dd85aeeb..f57adc65 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -56,7 +56,7 @@ type PageSidebarProps = { headerHidden?: boolean; }; -type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about"; +type SettingsSection = "account" | "appearance" | "memory" | "tools" | "encoder" | "about"; const hasActiveChild = (item: MenuItem, pathname: string): boolean => menuItemContainsPath(item, pathname); @@ -270,6 +270,12 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) { 模型配置 )} + {isAdmin && ( + openSettings("encoder")}> + + 本地编码器 + + )} {isAdmin && ( navigate("/page/strategy/settings/model-concurrency")} @@ -585,6 +591,12 @@ function LegacyAccordionSidebar({ 模型配置 )} + {isAdmin && ( + openSettings("encoder")}> + + 本地编码器 + + )} {isAdmin && ( navigate("/page/strategy/admin/login-whitelist")}> diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index bde7a5bc..705bdcbd 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -1,7 +1,7 @@ import type { Message } from "@langchain/langgraph-sdk"; import { useQuery } from "@tanstack/react-query"; import { FilesIcon, XIcon } from "lucide-react"; -import { usePathname } from "next/navigation"; +import { usePathname, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; import type { GroupImperativeHandle } from "react-resizable-panels"; @@ -15,6 +15,7 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { apiFetch } from "@/core/api"; import { + artifactPathname, listThreadArtifacts, mergeArtifactPaths, } from "@/core/artifacts/listing"; @@ -93,7 +94,13 @@ const ChatBox: React.FC<{ knowledgeReferencesEnabled = false, } = useThread(); const pathname = usePathname(); + const searchParams = useSearchParams(); + const requestedArtifact = useMemo(() => { + const value = searchParams.get("artifact")?.trim(); + return value ? artifactPathname(value) : null; + }, [searchParams]); const threadIdRef = useRef(threadId); + const openedRequestedArtifactRef = useRef(null); const artifactsCountRef = useRef(0); const layoutRef = useRef(null); const latestReferenceBatchIdRef = useRef(null); @@ -133,7 +140,7 @@ const ChatBox: React.FC<{ const { skills } = useSkills(); const canScanArtifactDirectory = - artifactDirectoryModeEnabled && + (artifactDirectoryModeEnabled || !!requestedArtifact) && !disableArtifacts && !isMock && !!threadId && @@ -151,17 +158,21 @@ const ChatBox: React.FC<{ ); const checkpointArtifactSignature = checkpointArtifacts.join("\n"); const availableArtifacts = useMemo( - () => - artifactDirectoryModeEnabled - ? mergeArtifactPaths( - checkpointArtifacts, - (artifactListingQuery.data?.files ?? []).map((file) => file.path), - ) - : checkpointArtifacts, + () => { + if (!artifactDirectoryModeEnabled && !requestedArtifact) { + return checkpointArtifacts; + } + const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map( + (file) => file.path, + ); + if (requestedArtifact) discoveredArtifacts.push(requestedArtifact); + return mergeArtifactPaths(checkpointArtifacts, discoveredArtifacts); + }, [ artifactDirectoryModeEnabled, artifactListingQuery.data?.files, checkpointArtifacts, + requestedArtifact, ], ); @@ -205,6 +216,29 @@ const ChatBox: React.FC<{ thread.isLoading, ); }, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]); + useEffect(() => { + if ( + disableArtifacts || + !requestedArtifact || + !threadId || + threadId === "new" + ) { + return; + } + const requestKey = `${threadId}:${artifactPathname(requestedArtifact)}`; + if (openedRequestedArtifactRef.current === requestKey) return; + openedRequestedArtifactRef.current = requestKey; + selectArtifact(requestedArtifact); + setArtifactsOpen(true); + setSideTab("document"); + }, [ + disableArtifacts, + requestedArtifact, + selectArtifact, + setArtifactsOpen, + threadId, + ]); + useEffect(() => { if (!shouldFetchBackendReferenceBatches) { setBackendReferenceBatches([]); diff --git a/frontend-web/src/components/workspace/knowledge-product-nav.tsx b/frontend-web/src/components/workspace/knowledge-product-nav.tsx new file mode 100644 index 00000000..96af3aca --- /dev/null +++ b/frontend-web/src/components/workspace/knowledge-product-nav.tsx @@ -0,0 +1,57 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { BookOpen, Database } from "lucide-react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { getStoredAuth } from "@/core/auth"; +import { + loadKnowledgeBaseProductVersion, + type KnowledgeBaseProductVersion, + updateKnowledgeBaseProductVersion, +} from "@/core/system-settings/api"; + +export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const; +export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal"; +export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`; + +export function useKnowledgeBaseProductVersion() { + return useQuery({ queryKey: KNOWLEDGE_PRODUCT_KEY, queryFn: loadKnowledgeBaseProductVersion, staleTime: 60_000 }); +} + +export function KnowledgeProductNav() { + const location = useLocation(); + const navigate = useNavigate(); + const client = useQueryClient(); + const version = useKnowledgeBaseProductVersion(); + const isAdmin = getStoredAuth()?.system_role === "admin"; + const save = useMutation({ + mutationFn: updateKnowledgeBaseProductVersion, + onSuccess: (value) => { + client.setQueryData(KNOWLEDGE_PRODUCT_KEY, value); + toast.success(`默认知识库体验已切换为 ${value}`); + }, + }); + const assistant = location.pathname.includes("/knowledge/assistant"); + + return ( +
+ + + {isAdmin && ( +
+ 默认产品版本 + +
+ )} +
+ ); +} diff --git a/frontend-web/src/components/workspace/llmwiki-knowledge-selector.tsx b/frontend-web/src/components/workspace/llmwiki-knowledge-selector.tsx index afdbd6f3..08274998 100644 --- a/frontend-web/src/components/workspace/llmwiki-knowledge-selector.tsx +++ b/frontend-web/src/components/workspace/llmwiki-knowledge-selector.tsx @@ -22,6 +22,10 @@ import { } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; +import { + type AssistantKnowledgeBase, + useAssistantKnowledgeBases, +} from "@/strategy-components/api/assistant-knowledge"; import { type LlmWikiKnowledgeBase, useAgentLlmWikiKnowledgeBases, @@ -66,10 +70,26 @@ function SpaceIcon({ item }: { item: LlmWikiKnowledgeBase }) { ); } +function AssistantSpaceIcon({ item }: { item: AssistantKnowledgeBase }) { + return ( + + + + ); +} + function SelectionContent({ rows, + assistantRows, value, + assistantValue, onChange, + onAssistantChange, search, onSearchChange, loading, @@ -79,8 +99,11 @@ function SelectionContent({ readOnly, }: { rows: LlmWikiKnowledgeBase[]; + assistantRows: AssistantKnowledgeBase[]; value: string[]; + assistantValue: string[]; onChange: (value: string[]) => void; + onAssistantChange: (value: string[]) => void; search: string; onSearchChange: (value: string) => void; loading: boolean; @@ -90,6 +113,7 @@ function SelectionContent({ readOnly: boolean; }) { const selected = useMemo(() => new Set(value), [value]); + const selectedAssistant = useMemo(() => new Set(assistantValue), [assistantValue]); const filtered = useMemo(() => { const query = search.trim().toLocaleLowerCase(); if (!query) return rows; @@ -97,11 +121,28 @@ function SelectionContent({ `${item.name} ${item.description}`.toLocaleLowerCase().includes(query), ); }, [rows, search]); + const filteredAssistant = useMemo(() => { + const query = search.trim().toLocaleLowerCase(); + if (!query) return assistantRows; + return assistantRows.filter((item) => + `${item.name} ${item.description ?? ""}`.toLocaleLowerCase().includes(query), + ); + }, [assistantRows, search]); const groups = useMemo(() => groupRows(filtered), [filtered]); const toggle = (id: string) => { if (readOnly) return; onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]); }; + const toggleAssistant = (id: string) => { + if (readOnly) return; + onAssistantChange( + selectedAssistant.has(id) + ? assistantValue.filter((item) => item !== id) + : [...assistantValue, id], + ); + }; + const selectedCount = value.length + assistantValue.length; + const hasRows = groups.length > 0 || filteredAssistant.length > 0; return ( <> @@ -127,7 +168,7 @@ function SelectionContent({
{error instanceof Error ? error.message : "知识空间读取失败"}
- ) : groups.length === 0 ? ( + ) : !hasRows ? (
{search ? "没有匹配的知识空间" @@ -136,91 +177,181 @@ function SelectionContent({ : "当前账号暂无可用知识空间"}
) : ( - groups.map((group, groupIndex) => ( -
- {groupIndex > 0 && ( - variant === "popover" - ? - :
- )} - {variant === "popover" ? ( - - {group.key === "personal" ? : }{group.label} - - ) : ( -
- {group.key === "personal" ? : }{group.label} -
- )} - {group.rows.map((item) => { - const rowContent = ( - <> - -
-
{item.name}
-
- {item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识 -
-
- {item.publication_status === "published" && ( - 公共 - )} - - ); - return variant === "popover" ? ( - toggle(item.id)} - onSelect={(event) => event.preventDefault()} - className={cn( - "my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7", - selected.has(item.id) && "bg-primary/8 focus:bg-primary/12", - )} - > - {rowContent} - + <> + {groups.map((group, groupIndex) => ( +
+ {groupIndex > 0 && ( + variant === "popover" + ? + :
+ )} + {variant === "popover" ? ( + + {group.key === "personal" ? : }{group.label} + ) : ( - - ); - })} -
- )) +
+ {group.key === "personal" ? : }{group.label} +
+ )} + {group.rows.map((item) => { + const rowContent = ( + <> + +
+
{item.name}
+
+ {item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识 +
+
+ {item.publication_status === "published" && ( + 公共 + )} + + ); + return variant === "popover" ? ( + toggle(item.id)} + onSelect={(event) => event.preventDefault()} + className={cn( + "my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7", + selected.has(item.id) && "bg-primary/8 focus:bg-primary/12", + )} + > + {rowContent} + + ) : ( + + ); + })} +
+ ))} + {filteredAssistant.length > 0 && ( +
+ {(groups.length > 0) && ( + variant === "popover" + ? + :
+ )} + {variant === "popover" ? ( + + 助手知识库 + + ) : ( +
+ 助手知识库 +
+ )} + {filteredAssistant.map((item) => { + const rowContent = ( + <> + +
+
{item.name}
+
+ Wiki · {item.page_count ?? 0} 页 · {item.entity_count ?? 0} 实体 +
+
+ + {item.is_global ? "总库" : "助手"} + + + ); + return variant === "popover" ? ( + toggleAssistant(item.id)} + onSelect={(event) => event.preventDefault()} + className={cn( + "my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7", + selectedAssistant.has(item.id) && "bg-primary/8 focus:bg-primary/12", + )} + > + {rowContent} + + ) : ( + + ); + })} +
+ )} + )}
- {readOnly ? `本对话已选 ${value.length} 个` : `已选择 ${value.length} 个`} + {readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`} {readOnly ? ( 对话开始后不可更改 ) : (
- -
@@ -232,7 +363,9 @@ function SelectionContent({ export function LlmWikiKnowledgeSelector({ value, + assistantValue = [], onChange, + onAssistantChange = () => undefined, source = "all", agentId, autoSelectAll = false, @@ -243,7 +376,9 @@ export function LlmWikiKnowledgeSelector({ variant = "popover", }: { value: string[]; + assistantValue?: string[]; onChange: (value: string[]) => void; + onAssistantChange?: (value: string[]) => void; source?: SelectorSource; agentId?: string | null; autoSelectAll?: boolean; @@ -257,28 +392,39 @@ export function LlmWikiKnowledgeSelector({ const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const runtime = useLlmWikiRuntime(); + const weknoraEnabled = runtime.data?.provider === "weknora"; const all = useLlmWikiKnowledgeBases( "all", - runtime.data?.provider === "weknora", + weknoraEnabled, ); const agent = useAgentLlmWikiKnowledgeBases( agentId, - runtime.data?.provider === "weknora" && source === "agent" && !!agentId, + weknoraEnabled && source === "agent" && !!agentId, ); - const rows = all.data ?? []; + const assistantBases = useAssistantKnowledgeBases(); + const rows = weknoraEnabled ? (all.data ?? []) : []; + const assistantRows = assistantBases.data ?? []; const defaultIds = useMemo(() => { if (source !== "agent") { return rows.map((item) => item.id); } return (agent.data ?? []).map((item) => item.id); }, [agent.data, rows, source]); - const loading = all.isLoading || (source === "agent" && !!agentId && agent.isLoading); - const error = all.error ?? (source === "agent" ? agent.error : null); + const loading = runtime.isLoading || (weknoraEnabled && all.isLoading) || assistantBases.isLoading || (source === "agent" && !!agentId && agent.isLoading); + const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? assistantBases.error; const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]); + const assistantAvailableIds = useMemo( + () => new Set(assistantRows.map((item) => item.id)), + [assistantRows], + ); const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`; const initialized = useRef(null); useEffect(() => { + if (!runtime.isLoading && !weknoraEnabled) { + if (value.length > 0) onChange([]); + return; + } if (!all.data || (source === "agent" && agentId && !agent.data) || initialized.current === initializationKey) return; initialized.current = initializationKey; const valid = value.filter((id) => availableIds.has(id)); @@ -288,19 +434,30 @@ export function LlmWikiKnowledgeSelector({ if (next.length !== value.length || next.some((id, index) => id !== value[index])) { onChange(next); } - }, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, source, value]); + }, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, runtime.isLoading, source, value, weknoraEnabled]); + + useEffect(() => { + if (!assistantBases.data) return; + const valid = assistantValue.filter((id) => assistantAvailableIds.has(id)); + if (valid.length !== assistantValue.length || valid.some((id, index) => id !== assistantValue[index])) { + onAssistantChange(valid); + } + }, [assistantAvailableIds, assistantBases.data, assistantValue, onAssistantChange]); useEffect(() => { if (!open) setSearch(""); }, [open]); - if (runtime.isError || runtime.data?.provider !== "weknora") return null; + if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null; const content = (
- {value.length} 个 + {value.length + assistantValue.length} 个
{content}
@@ -333,10 +490,15 @@ export function LlmWikiKnowledgeSelector({ const selectedNames = value .map((id) => rows.find((item) => item.id === id)?.name) .filter((name): name is string => !!name); - const selectedLabel = selectedNames.length > 0 - ? selectedNames.join("、") - : value.length > 0 - ? `已选 ${value.length} 个知识空间` + const selectedAssistantNames = assistantValue + .map((id) => assistantRows.find((item) => item.id === id)?.name) + .filter((name): name is string => !!name); + const allSelectedNames = [...selectedNames, ...selectedAssistantNames]; + const selectedCount = value.length + assistantValue.length; + const selectedLabel = allSelectedNames.length > 0 + ? allSelectedNames.join("、") + : selectedCount > 0 + ? `已选 ${selectedCount} 个知识空间` : label; return ( @@ -348,20 +510,20 @@ export function LlmWikiKnowledgeSelector({ size="sm" className={cn( "h-8 max-w-52 gap-1.5 rounded-full border-0 bg-transparent px-2.5 text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground", - value.length > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary", + selectedCount > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary", className, )} disabled={disabled || loading} - title={value.length > 0 ? selectedLabel : undefined} + title={selectedCount > 0 ? selectedLabel : undefined} > {loading ? : } {selectedLabel} - {value.length > 0 && ( + {selectedCount > 0 && ( - {value.length} + {selectedCount} )} - {value.length > 0 && value.length === rows.length && } + {selectedCount > 0 && selectedCount === rows.length + assistantRows.length && } diff --git a/frontend-web/src/components/workspace/settings/settings-dialog.tsx b/frontend-web/src/components/workspace/settings/settings-dialog.tsx index 00f587dd..e104cc64 100644 --- a/frontend-web/src/components/workspace/settings/settings-dialog.tsx +++ b/frontend-web/src/components/workspace/settings/settings-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { BellIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react"; +import { BellIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { @@ -14,16 +14,19 @@ import { AboutSettingsPage } from "@/components/workspace/settings/about-setting import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page"; import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page"; import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page"; -import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page"; -import { useI18n } from "@/core/i18n/hooks"; -import { cn } from "@/lib/utils"; +import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page"; +import { getStoredAuth } from "@/core/auth"; +import { useI18n } from "@/core/i18n/hooks"; +import { cn } from "@/lib/utils"; +import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace"; type SettingsSection = | "account" | "appearance" - | "memory" - | "tools" - | "about"; + | "memory" + | "tools" + | "encoder" + | "about"; type SettingsDialogProps = React.ComponentProps & { defaultSection?: SettingsSection; @@ -31,7 +34,8 @@ type SettingsDialogProps = React.ComponentProps & { export function SettingsDialog(props: SettingsDialogProps) { const { defaultSection = "appearance", ...dialogProps } = props; - const { t } = useI18n(); + const { t } = useI18n(); + const isAdmin = getStoredAuth()?.system_role === "admin"; const [activeSection, setActiveSection] = useState(defaultSection); @@ -63,7 +67,10 @@ export function SettingsDialog(props: SettingsDialogProps) { // label: t.settings.sections.memory, // icon: BrainIcon, // }, - { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon }, + { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon }, + ...(isAdmin + ? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }] + : []), { id: "account", label: "个人信息", @@ -75,7 +82,8 @@ export function SettingsDialog(props: SettingsDialogProps) { t.settings.sections.appearance, t.settings.sections.memory, t.settings.sections.tools, - t.settings.sections.about, + t.settings.sections.about, + isAdmin, ], ); return ( @@ -125,7 +133,8 @@ export function SettingsDialog(props: SettingsDialogProps) { props.onOpenChange?.(false)} /> )} {activeSection === "memory" && } - {activeSection === "tools" && } + {activeSection === "tools" && } + {activeSection === "encoder" && isAdmin && } {activeSection === "about" && } diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx index d63df2c6..89f53cd0 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -93,6 +93,7 @@ import { useViewMode, } from "@/core/system-settings/hooks"; import type { Skill } from "@/core/skills/type"; +import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge"; import { formatDateTime } from "@/core/utils/datetime"; import { env } from "@/env"; import { desensitize } from "@/lib/desensitize"; @@ -101,9 +102,16 @@ import { cn } from "@/lib/utils"; import { SettingsSection } from "./settings-section"; import { SkillStateSheet } from "./skill-state-sheet"; -export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) { - const { t } = useI18n(); - return ( +export function SkillSettingsPage({ + onClose, + embedded = false, +}: { + onClose?: () => void; + embedded?: boolean; +} = {}) { + const { t } = useI18n(); + if (embedded) return ; + return ( void; + /** Admin list can inject its single batched binding query here (never N+1). */ + knowledgeBindings?: SkillKnowledgeBinding[]; }) { const isCard = layout === "card"; const { t } = useI18n(); @@ -858,6 +869,20 @@ export function SkillRow({ 常驻 )} + {knowledgeBindings.slice(0, 3).map((binding) => ( + + 知识库:{binding.target_name || binding.target_id} + {binding.status === "stale" ? "(待更新)" : ""} + + ))} + {knowledgeBindings.length > 3 && ( + +{knowledgeBindings.length - 3} + )} ); diff --git a/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx b/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx new file mode 100644 index 00000000..732a0912 --- /dev/null +++ b/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx @@ -0,0 +1,280 @@ +import { CheckCircle2, Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Progress } from "@/components/ui/progress"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useAllSkillsAdmin, useSkills } from "@/core/skills/hooks"; +import { useLlmWikiKnowledgeBases, useLlmWikiRuntime } from "@/strategy-components/api/llmwiki"; +import { + type SkillKnowledgeTarget, + useSkillKnowledgeActions, + useSkillKnowledgeBindings, + useSkillKnowledgeJobs, +} from "@/strategy-components/api/skill-knowledge"; + +const PHASE_LABELS: Record = { + scanning: "扫描知识文件", + parsing: "解析资料", + extracting: "抽取实体关系", + awaiting_review: "等待审核", + merging: "合并实体关系", + writing_wiki: "写入 Wiki", + writing_sources: "上传知识资料", + generating_wiki: "分析并生成分类 Wiki", + vectorizing_wiki: "Wiki 向量化", + syncing_assistant: "同步知识梳理总库", + activating: "激活新版本", + completed: "完成", +}; + +const STATUS_LABELS: Record = { + queued: "等待处理", + running: "处理中", + awaiting_review: "等待管理员审核", + completed: "已完成", + partial_failed: "部分失败", + failed: "失败", + canceled: "已取消", +}; + +export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean }) { + const visibleSkills = useSkills(); + const allSkills = useAllSkillsAdmin(adminMode); + const skills = adminMode ? allSkills.skills : visibleSkills.skills; + const isLoading = adminMode ? allSkills.isLoading : visibleSkills.isLoading; + const liveSkills = useMemo(() => skills.filter((skill) => (skill.state ?? "active") === "active"), [skills]); + const [selectedSkills, setSelectedSkills] = useState([]); + const [skillQuery, setSkillQuery] = useState(""); + const [selectedTargetKey, setSelectedTargetKey] = useState(null); + const runtime = useLlmWikiRuntime(); + const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora"); + const bindings = useSkillKnowledgeBindings(undefined, !adminMode); + const jobs = useSkillKnowledgeJobs(!adminMode); + const actions = useSkillKnowledgeActions(); + + const targets = useMemo(() => { + return (knowledgeBases.data ?? []) + .filter((kb) => { + const strategy = kb.indexing_strategy ?? {}; + const capabilities = kb.capabilities ?? {}; + const wikiEnabled = + kb.type === "wiki" || + kb.wiki_index_enabled === true || + strategy.wiki_enabled === true || + capabilities.wiki_enabled === true || + (kb.wiki_config && Object.keys(kb.wiki_config).length > 0); + return kb.can_write && kb.remote_status !== "missing" && !kb.is_conversation_deposit && kb.type !== "faq" && wikiEnabled; + }) + .map((kb) => ({ + target_type: "weknora", + target_mode: "wiki", + target_id: kb.id, + target_name: kb.name, + remote_business_key: `weknora:kb:${kb.id}`, + })); + }, [knowledgeBases.data]); + + const filteredSkills = useMemo(() => { + const needle = skillQuery.trim().toLowerCase(); + if (!needle) return liveSkills; + return liveSkills.filter((skill) => { + const tagText = (skill.tags ?? []).map((tag) => tag.name).join(" "); + const haystack = [ + skill.name, + skill.name_zh, + skill.description, + skill.category, + tagText, + ] + .filter(Boolean) + .join("\n") + .toLowerCase(); + return haystack.includes(needle); + }); + }, [liveSkills, skillQuery]); + + const filteredSkillNames = useMemo(() => filteredSkills.map((skill) => skill.name), [filteredSkills]); + const allFilteredSelected = filteredSkillNames.length > 0 && filteredSkillNames.every((name) => selectedSkills.includes(name)); + + const toggleFilteredSkills = () => { + if (!filteredSkillNames.length) return; + setSelectedSkills((current) => { + if (allFilteredSelected) { + const filtered = new Set(filteredSkillNames); + return current.filter((name) => !filtered.has(name)); + } + return [...current, ...filteredSkillNames.filter((name) => !current.includes(name))]; + }); + }; + + useEffect(() => { + if (selectedTargetKey && !targets.some((target) => `${target.target_type}:${target.target_id}` === selectedTargetKey)) { + setSelectedTargetKey(null); + } + }, [selectedTargetKey, targets]); + + const toggle = (values: string[], value: string, setter: (next: string[]) => void) => + setter(values.includes(value) ? values.filter((item) => item !== value) : [...values, value]); + + const submit = async () => { + const selectedTargets = targets.filter((target) => + selectedTargetKey === `${target.target_type}:${target.target_id}`, + ); + if (!selectedSkills.length || !selectedTargets.length) { + toast.error("请至少选择一个技能和一个目标知识库"); + return; + } + try { + const job = await actions.createJob.mutateAsync({ + skill_names: selectedSkills, + targets: selectedTargets, + review_mode: "off", + }); + toast.success(`已创建 ${job.total_count} 个归纳项`); + setSelectedSkills([]); + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)); + } + }; + + return ( +
+
+
+

归纳技能知识

+

选择技能和一个目标知识库后,系统会在后台提取 Markdown 等知识资料,忽略 Python 等代码文件,自动生成 Wiki、关系和向量索引。

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

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

+ )} +
+

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

+
+
+
+ 第二步:选择一个目标知识库 +
+
+ {targets.map((target) => { + const key = `${target.target_type}:${target.target_id}`; + return ( + + ); + })} + {(runtime.isLoading || knowledgeBases.isLoading) && } + {!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && ( +

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

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

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

+ )} +
+
+
+ 第三步:提交后可离开本页。归纳、Wiki 生成、向量化及同步到“知识梳理”总库均在后台完成。 +
+ +
+
+
+ +
+
+
+

我的归纳任务

任务会自动刷新,失败项可直接重试

+ +
+ +
+ {(jobs.data ?? []).map((job) => { + const countedDone = job.success_count + job.partial_failed_count + job.failed_count; + const itemDone = (job.items ?? []).filter((item) => + ["completed", "partial_failed", "failed", "canceled"].includes(item.status), + ).length; + const done = Math.max(countedDone, itemDone); + const progress = job.total_count ? Math.round((done / job.total_count) * 100) : 0; + return ( +
+
{STATUS_LABELS[job.status] || job.status}{done}/{job.total_count} 项{new Date(job.created_at).toLocaleString()}
+ + {job.items?.map((item) => ( +
+ {item.skill_name} + {PHASE_LABELS[item.phase] || STATUS_LABELS[item.status] || item.phase} + {item.progress}% + {item.status === "failed" && } +
+ ))} +
+ ); + })} + {!jobs.data?.length &&

暂无归纳任务

} +
+
+
+
+
已归纳关联:{bindings.data?.length ?? 0}
+
{bindings.data?.map((binding) =>
{binding.skill_name}已归纳到 {binding.target_name}{STATUS_LABELS[binding.status] || binding.status}{adminMode && }
)}
+
+
+
+ ); +} diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index 107023e2..8e73f74e 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -14,7 +14,7 @@ import { SidebarMenuItem, } from "@/components/ui/sidebar"; import { useI18n } from "@/core/i18n/hooks"; -import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react"; +import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react"; import { KnowledgeRagToggle } from "./knowledge-rag-toggle"; import { SidebarCollapseButton } from "./workspace-header"; @@ -65,6 +65,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [ path: "/page/workspace/chats", match: (pathname) => pathname === "/page/workspace/chats", }, + { + id: "workspace-library", + label: "资料库", + icon: Library, + path: "/page/workspace/library", + match: (pathname) => pathname === "/page/workspace/library", + }, { id: "workspace-agents", label: "智能体", diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 8e4ac50d..57c71201 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -83,7 +83,7 @@ export function WorkspaceNavMenu() { const isAdmin = getStoredAuth()?.system_role === "admin"; const [settingsOpen, setSettingsOpen] = useState(false); const [settingsDefaultSection, setSettingsDefaultSection] = useState< - "appearance" | "memory" | "tools" | "about" + "appearance" | "memory" | "tools" | "encoder" | "about" >("appearance"); const [notificationOpen, setNotificationOpen] = useState(false); const [mounted, setMounted] = useState(false); @@ -189,6 +189,17 @@ export function WorkspaceNavMenu() { {t.common.settings}
+ {isAdmin && ( + { + setSettingsDefaultSection("encoder"); + setSettingsOpen(true); + }} + > + + 本地编码器 + + )} {isAdmin && ( 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 new file mode 100644 index 00000000..ade3d60c --- /dev/null +++ b/frontend-web/src/core/artifacts/library.ts @@ -0,0 +1,113 @@ +import { apiFetch } from "@/core/api"; +import { getBackendBaseURL } from "@/core/config"; + +export type ArtifactLibraryKind = + | "all" + | "document" + | "spreadsheet" + | "text_data" + | "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; + mimeType: string | null; + modifiedAt: string; + fileKind: Exclude; +} + +export interface ArtifactLibraryResponse { + total: number; + offset: number; + limit: number; + items: ArtifactLibraryItem[]; +} + +export async function fetchArtifactLibrary({ + query, + fileKind, + sourceType, + offset, + limit, + refresh = false, + signal, +}: { + query?: string; + fileKind: ArtifactLibraryKind; + sourceType: ArtifactLibrarySource; + offset: number; + limit: number; + refresh?: boolean; + signal?: AbortSignal; +}): Promise { + const search = new URLSearchParams({ + file_kind: fileKind, + source_type: sourceType, + offset: String(offset), + limit: String(limit), + }); + const normalizedQuery = query?.trim(); + if (normalizedQuery) search.set("query", normalizedQuery); + if (refresh) search.set("refresh", "true"); + + const response = await apiFetch( + `${getBackendBaseURL()}/api/artifact-library?${search.toString()}`, + { method: "GET", signal }, + ); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error( + `Failed to load artifact library (${response.status})${detail ? `: ${detail}` : ""}`, + ); + } + + const data = (await response.json()) as { + total?: number; + offset?: number; + limit?: number; + items?: Array<{ + 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; + mime_type?: string | null; + modified_at: string; + file_kind: Exclude; + }>; + }; + + return { + total: data.total ?? 0, + offset: data.offset ?? offset, + limit: data.limit ?? limit, + items: (data.items ?? []).map((item) => ({ + 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, + mimeType: item.mime_type ?? null, + modifiedAt: item.modified_at, + fileKind: item.file_kind, + })), + }; +} diff --git a/frontend-web/src/core/llmwiki/wiki-page-markdown.ts b/frontend-web/src/core/llmwiki/wiki-page-markdown.ts index 17a2a60f..6a47dfc0 100644 --- a/frontend-web/src/core/llmwiki/wiki-page-markdown.ts +++ b/frontend-web/src/core/llmwiki/wiki-page-markdown.ts @@ -149,7 +149,7 @@ export function buildWikiIndexCatalogMarkdown( buildPageUrl: WikiPageUrlBuilder = buildWeKnoraWikiPageUrl, indexGroups: WikiIndexGroup[] = [], ): string { - if (indexGroups.length > 0) { + if (indexGroups.some((group) => group.items.length > 0)) { const sections: string[] = []; for (const group of indexGroups) { if (group.items.length === 0) continue; diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 697c3025..de466f3c 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -61,6 +61,7 @@ export const sidebarMenuItems: MenuItem[] = [ icon: HelpCircle, children: [ { id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" }, + { id: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" }, { id: "multi-agent-roundtable", label: "多智能体会商", diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index 24a212b0..b12342cb 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -9,6 +9,31 @@ export interface PromptPrefixSettings { ordinary_qa_markdown_format_enabled: boolean; } +export type KnowledgeBaseProductVersion = "v1" | "v2"; + +export async function loadKnowledgeBaseProductVersion(): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`, + ); + if (!response.ok) throw new Error(`Failed to load knowledge base product version: ${response.status}`); + return ((await response.json()) as { version: KnowledgeBaseProductVersion }).version; +} + +export async function updateKnowledgeBaseProductVersion( + version: KnowledgeBaseProductVersion, +): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ version }), + }, + ); + if (!response.ok) throw new Error(`Failed to update knowledge base product version: ${response.status}`); + return ((await response.json()) as { version: KnowledgeBaseProductVersion }).version; +} + export interface PluginDomainAgentMappingDto { id: string; domain: string; diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index b6ad8102..cfbfbe5e 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -1512,10 +1512,16 @@ export function useThreadStream({ ) ? mergedContext.llmwiki_knowledge_base_ids : []; + const selectedAssistantKnowledgeIds = Array.isArray( + mergedContext.assistant_knowledge_base_ids, + ) + ? mergedContext.assistant_knowledge_base_ids + : []; const ragModeEnabled = (referenceModeEntryEnabled && localSettings.display.ragModeEnabled) || isThreadRagModeLocked(threadId) || - selectedLlmWikiIds.length > 0; + selectedLlmWikiIds.length > 0 || + selectedAssistantKnowledgeIds.length > 0; if (ragModeEnabled) { lockThreadRagMode(threadId); } @@ -1693,7 +1699,7 @@ export function useThreadStream({ ? undefined : thread.error; - const mergedThread = { + const mergedThread = { ...thread, error: visibleThreadError, values: mergedValues, @@ -1703,7 +1709,7 @@ export function useThreadStream({ : mergedMessages, isLoading: thread.isLoading || streamHoldOpen, stop: handleThreadStop, - } as typeof thread; + } as typeof thread; // 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。 // 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话 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 2dba69ed..e69fad27 100644 --- a/frontend-web/src/pages/AdminSkillsPage.tsx +++ b/frontend-web/src/pages/AdminSkillsPage.tsx @@ -8,6 +8,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"; @@ -21,14 +22,28 @@ import { SkillRow } from "@/components/workspace/settings/skill-settings-page"; import { getStoredAuth } from "@/core/auth"; import { useAllSkillsAdmin } from "@/core/skills/hooks"; import { desensitize } from "@/lib/desensitize"; +import { useSkillKnowledgeBindings } from "@/strategy-components/api/skill-knowledge"; 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 : "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; @@ -56,7 +71,8 @@ export default function AdminSkillsPage() {
@@ -126,6 +142,9 @@ export default function AdminSkillsPage() { skill={skill} currentUserId={currentUserId} isAdmin + knowledgeBindings={(knowledgeBindings.data ?? []).filter( + (binding) => binding.skill_name === skill.name, + )} /> ))} diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index c90ceca6..e0f73872 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -128,6 +128,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 { @@ -344,11 +364,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); @@ -359,6 +385,10 @@ export default function AgentChatPage({ setSelectedKnowledgeBaseIds(ids); setKnowledgeSelectionReady(true); }, []); + const changeAssistantKnowledgeSelection = useCallback((ids: string[]) => { + setSelectedAssistantKnowledgeBaseIds(ids); + setKnowledgeSelectionReady(true); + }, []); useEffect(() => { activeThreadIdRef.current = threadId; @@ -368,17 +398,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); @@ -393,18 +429,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); } }) @@ -427,10 +474,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], ); // 任务工作区(taskBind):每次运行都带隐藏任务上下文(任务id/名称/内容), // 新建对话的消息历史里没有开场信息时模型也能拿到任务id(见 taskBindRunContext)。 @@ -446,7 +496,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, + } : {}), ...(taskBindRunContextValue ?? {}), }, @@ -1043,9 +1096,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="知识空间" @@ -1130,7 +1187,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 new file mode 100644 index 00000000..2424fe5d --- /dev/null +++ b/frontend-web/src/pages/ArtifactLibraryPage.tsx @@ -0,0 +1,559 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { + ArrowDownWideNarrow, + Bot, + ChevronLeft, + ChevronRight, + Download, + FileText, + FileX2, + Library, + Loader2, + MessagesSquare, + MessageSquareShare, + RefreshCw, + Search, +} from "lucide-react"; +import Link from "next/link"; +import { useEffect, useMemo, useRef, useState } from "react"; +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, + 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"; +import { formatDateTime } from "@/core/utils/datetime"; +import { + getFileExtensionDisplayName, + getFileIcon, +} from "@/core/utils/files"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; + +const PAGE_SIZE = 50; + +const FILTERS: Array<{ value: ArtifactLibraryKind; label: string }> = [ + { value: "all", label: "全部" }, + { value: "document", label: "文档" }, + { value: "spreadsheet", label: "表格" }, + { value: "text_data", label: "文本与数据" }, + { value: "archive", label: "压缩包" }, + { 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"]; + const index = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + units.length - 1, + ); + const value = bytes / 1024 ** index; + const digits = index === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 2; + return `${value.toFixed(digits)} ${units[index]}`; +} + +function sourceConversationPath(item: ArtifactLibraryItem): string { + const basePath = + item.threadType === "bootstrap" + ? bootstrapResumePath(item.threadId) + : pathOfThread({ + thread_id: item.threadId, + metadata: item.agentId ? { agent_id: item.agentId } : undefined, + }); + const search = new URLSearchParams({ artifact: item.path }); + return `${basePath}?${search.toString()}`; +} + +function FileRow({ + item, + downloading, + onDownload, +}: { + item: ArtifactLibraryItem; + 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 ( + + + + + {getFileIcon(item.name, "size-5")} + + + + {displayName} + + + {getFileExtensionDisplayName(item.name)} · {sourceName} · 来自「 + {sourceTitle}」 + + + {getFileExtensionDisplayName(item.name)} 文件 + + + + + +
+ + + + + + {sourceName} + + + {item.sourceType === "agent" ? "智能体 · " : ""} + {sourceTitle} + + +
+ + + {formatDateTime(item.modifiedAt)} + + + {formatBytes(item.sizeBytes)} + + +
+ + {isMarkdownArtifact(item.name) && ( + + )} + +
+ + + ); +} + +function LoadingRows() { + return Array.from({ length: 5 }, (_, index) => ( + + +
+ +
+ + +
+
+ + + + + + + )); +} + +export default function ArtifactLibraryPage() { + const { t } = useI18n(); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [fileKind, setFileKind] = useState("all"); + const [sourceType, setSourceType] = useState("all"); + const [page, setPage] = useState(1); + const [downloadingKey, setDownloadingKey] = useState(null); + const forceRefreshRef = useRef(false); + + useEffect(() => { + document.title = `资料库 - ${t.pages.appName}`; + }, [t.pages.appName]); + + useEffect(() => { + const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300); + return () => window.clearTimeout(timer); + }, [search]); + + useEffect(() => { + setPage(1); + }, [debouncedSearch, fileKind, sourceType]); + + const offset = (page - 1) * PAGE_SIZE; + const libraryQuery = useQuery({ + 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, + signal, + }); + } finally { + forceRefreshRef.current = false; + } + }, + retry: false, + }); + + const total = libraryQuery.data?.total ?? 0; + const items = libraryQuery.data?.items ?? []; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const hasPrevious = page > 1; + const hasNext = page < totalPages; + + useEffect(() => { + if (page > totalPages) setPage(totalPages); + }, [page, totalPages]); + + const itemKeys = useMemo( + () => + 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, + format: DownloadFormat, + ) => { + const key = artifactDownloadKey(item, format); + if (downloadingKey) return; + setDownloadingKey(key); + try { + 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 : "下载失败"); + } finally { + setDownloadingKey(null); + } + }; + + const handleRefresh = () => { + forceRefreshRef.current = true; + void libraryQuery.refetch(); + }; + + return ( + + + +
+
+
+
+
+
+ +

资料库

+
+

+ 汇总当前账号在对话中生成的文件,不包含上传文件、图片、音频和视频。 +

+
+ +
+ +
+ + setSearch(event.target.value)} + /> +
+ +
+
+
+ {FILTERS.map((filter) => ( + + ))} +
+ +
+
+ + 最近生成 +
+
+
+
+ + +
+
+ {libraryQuery.isLoading ? "正在统计文件…" : `共 ${total} 个文件`} +
+ +
+ + + + + + + + + + + + {libraryQuery.isLoading ? ( + + ) : ( + items.map((item) => { + const key = `${item.threadId}:${item.path}`; + const activeDownload = downloadingKey?.startsWith(`${key}:`) + ? downloadingKey.endsWith(":word") + ? "word" + : "original" + : null; + return ( + + void handleDownload(target, format) + } + /> + ); + }) + )} + +
文件名来源生成时间大小 + 操作 +
+
+ + {!libraryQuery.isLoading && libraryQuery.isError && ( +
+ +
+

资料库加载失败

+

+ {libraryQuery.error instanceof Error + ? libraryQuery.error.message + : "请稍后重试"} +

+
+ +
+ )} + + {!libraryQuery.isLoading && !libraryQuery.isError && items.length === 0 && ( +
+ +
+

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

+

+ 对话中生成的文档、表格和数据文件会自动出现在这里。 +

+
+
+ )} + + {!libraryQuery.isLoading && !libraryQuery.isError && totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend-web/src/pages/AssistantKnowledgePage.tsx b/frontend-web/src/pages/AssistantKnowledgePage.tsx new file mode 100644 index 00000000..c60de7ca --- /dev/null +++ b/frontend-web/src/pages/AssistantKnowledgePage.tsx @@ -0,0 +1,631 @@ +import { + ArrowLeft, + BookOpenText, + Database, + Edit3, + Loader2, + Plus, + RefreshCw, + Search, + Trash2, + Upload, +} from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; +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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { KnowledgeProductNav } from "@/components/workspace/knowledge-product-nav"; +import { getStoredAuth } from "@/core/auth"; +import { buildAppHashUrl } from "@/core/llmwiki/source-links"; +import { + searchAssistantWiki, + useAssistantImportJobs, + useAssistantKnowledgeActions, + useAssistantKnowledgeBases, + useAssistantKnowledgeSources, + useAssistantWikiPages, + type AssistantKnowledgeBase, +} from "@/strategy-components/api/assistant-knowledge"; +import { + WikiLibraryReader, + type LibraryWikiPage, +} from "@/strategy-components/components/resource-management/llmwiki/WikiLibraryReader"; +import { WikiPackageTransfers } from "@/strategy-components/components/resource-management/llmwiki/WikiPackageTransfers"; + +export default function AssistantKnowledgePage() { + const isAdmin = getStoredAuth()?.system_role === "admin"; + const [params, setParams] = useSearchParams(); + const baseId = params.get("base_id"); + const slug = params.get("wikiSlug"); + const bases = useAssistantKnowledgeBases(); + const actions = useAssistantKnowledgeActions(); + const pages = useAssistantWikiPages("", baseId); + const sources = useAssistantKnowledgeSources(baseId); + const jobs = useAssistantImportJobs(baseId); + const client = useQueryClient(); + const [tab, setTab] = useState("wiki"); + const [createOpen, setCreateOpen] = useState(false); + const [editingBase, setEditingBase] = + useState(null); + const [name, setName] = useState(""); + const [query, setQuery] = useState(""); + const [searching, setSearching] = useState(false); + const [searchResult, setSearchResult] = useState + > | null>(null); + const upload = useRef(null); + const base = bases.data?.find((b) => b.id === baseId); + const libraryPages = useMemo( + () => + (pages.data || []).map((p) => ({ + ...p, + content: p.content_markdown, + status: "published", + aliases: p.aliases || [], + source_refs: p.source_refs || [], + in_links: p.in_links || [], + out_links: p.out_links || [], + version: 1, + })), + [pages.data], + ); + const active = + libraryPages.find((p) => p.slug === slug) || + libraryPages.find((p) => p.page_type === "index") || + libraryPages[0] || + null; + const navigatePage = (nextSlug: string) => { + setParams({ base_id: baseId!, wikiSlug: nextSlug }); + setTab("wiki"); + }; + const finishedJobs = (jobs.data || []) + .filter((j) => j.status === "completed") + .map((j) => j.id) + .join(","); + useEffect(() => { + void client.invalidateQueries({ + queryKey: ["assistant-knowledge", "pages"], + }); + void client.invalidateQueries({ + queryKey: ["assistant-knowledge", "bases"], + }); + }, [finishedJobs, client]); + useEffect(() => { + setSearchResult(null); + setQuery(""); + }, [baseId]); + const create = async () => { + if (!name.trim()) return; + try { + const b = await actions.createBase.mutateAsync({ name: name.trim() }); + setCreateOpen(false); + setName(""); + setParams({ base_id: b.id }); + } catch (e) { + toast.error((e as Error).message); + } + }; + const search = async () => { + if (!query.trim() || !baseId) return; + setSearching(true); + try { + setSearchResult(await searchAssistantWiki(query, baseId)); + } catch (e) { + toast.error((e as Error).message); + } finally { + setSearching(false); + } + }; + const refresh = () => + void client.invalidateQueries({ queryKey: ["assistant-knowledge"] }); + const removeBase = async (target: AssistantKnowledgeBase) => { + if (target.is_global) { + toast.error("知识梳理总库承担全库沉淀,不能删除"); + return; + } + if ( + !window.confirm( + `确定删除助手知识库“${target.name}”吗?库内 Wiki、向量、实体、关系和导入记录都会删除。`, + ) + ) + return; + try { + await actions.deleteBase.mutateAsync(target.id); + if (baseId === target.id) setParams({}); + toast.success("助手知识库已删除"); + } catch (error) { + toast.error((error as Error).message); + } + }; + return ( +
+ + {!baseId ? ( +
+
+
+

助手知识库

+

+ 浏览分类 + Wiki、关联知识与来源。普通知识库向量化完成后自动沉淀到知识梳理。 +

+
+ {isAdmin && ( + + )} +
+ {bases.isLoading && } + {bases.error && ( +

{bases.error.message}

+ )} +
+ {bases.data?.map((b) => ( +
+ + {isAdmin && ( +
+ + {!b.is_global && ( + + )} + {b.is_global && ( + + 系统大库不可删除 + + )} +
+ )} +
+ ))} +
+ {!bases.isLoading && !bases.data?.length && isAdmin && ( + + )} +
+ ) : ( + <> +
+ +
+ +
+
+

+ {base?.name || "助手知识库"} +

+

+ {base?.is_global + ? "全库知识梳理 · 自动同步分析成果" + : "Wiki 知识空间"} +

+
+
+ + {isAdmin && ( + + )} + {isAdmin && base && !base.is_global && ( + + )} + {isAdmin && ( + + )} +
+ { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + try { + await actions.uploadWeKnoraPackage.mutateAsync({ + file, + assistantBaseId: baseId, + }); + toast.success("文件已上传,后台正在导入"); + } catch (err) { + toast.error((err as Error).message); + } + }} + /> +
+ +
+ + + Wiki({base?.page_count || 0}) + + 知识检索 + 来源与同步 + {isAdmin && ( + 导入 / 导出记录 + )} + +
+ + + buildAppHashUrl( + `/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: baseId, wikiSlug: s })}`, + ) + } + mappingId={ + active?.origins?.find((o) => o.mapping_id)?.mapping_id + } + initiallyCollapseTree + /> + + +
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void search(); + }} + /> + +
+ {searchResult && ( + <> + + {searchResult.retrieval_mode === "vector" + ? "知识正文 / 片段 · 向量检索" + : searchResult.retrieval_mode === "empty" + ? "没有匹配结果" + : "知识正文 / 片段 · 关键词检索(暂无匹配模型的向量)"} + +
+ {searchResult.results.map((p) => ( + + ))} +
+ + )} +
+ +
+ {sources.data?.map((s) => ( +
+

{s.source_name}

+

+ 上次同步:{new Date(s.updated_at).toLocaleString()} +

+ {isAdmin && ( + + )} +
+ ))} +
+
+ {isAdmin && ( + + +

导入与同步记录

+ {jobs.data?.map((j) => ( +
+
+ + {{ + completed: "已完成", + failed: "失败", + running: "处理中", + queued: "排队中", + }[j.status] || j.status} + + + {new Date(j.created_at).toLocaleString()} + +
+

+ {j.counts?.wiki_pages || 0} Wiki ·{" "} + {j.counts?.vectors || 0} 向量 · {j.counts?.entities || 0}{" "} + 实体 · {j.counts?.relations || 0} 关系 +

+ {j.last_error_message && ( +

+ {j.last_error_message} +

+ )} +
+ ))} +
+ )} +
+ + )} + + + + 新建助手知识库 + + setName(e.target.value)} + /> + + + + + + {editingBase && ( + setEditingBase(null)} + onSave={async (input) => { + try { + await actions.updateBase.mutateAsync({ + id: editingBase.id, + ...input, + }); + setEditingBase(null); + toast.success("助手知识库信息已保存"); + } catch (error) { + toast.error((error as Error).message); + } + }} + /> + )} +
+ ); +} + +function EditAssistantBaseDialog({ + base, + pending, + onClose, + onSave, +}: { + base: AssistantKnowledgeBase; + pending: boolean; + onClose: () => void; + onSave: (input: { name: string; description: string }) => Promise; +}) { + const [name, setName] = useState(base.name); + const [description, setDescription] = useState(base.description || ""); + + return ( + { + if (!open && !pending) onClose(); + }} + > + +
{ + event.preventDefault(); + if (!pending && name.trim()) { + void onSave({ + name: name.trim(), + description: description.trim(), + }); + } + }} + > + + 编辑助手知识库 + + 修改知识库名称和描述。知识内容、向量和关联关系不会受到影响。 + + +
+
+ + setName(event.target.value)} + maxLength={255} + disabled={pending} + required + /> +
+
+ +