diff --git a/DESIGN.md b/DESIGN.md index 85ecdaa..a342caa 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -39,6 +39,8 @@ 组件不得直接使用原始颜色值。主题差异只在令牌层定义,业务组件仅引用语义令牌。 +两种主题必须保持相同的信息层级,但不要求机械地反转明暗。浅色主题以白色主内容画布、冰蓝灰侧栏和轻微着色的顶栏建立空间关系;深色主题使用深海军蓝与蓝灰表面逐层提亮,避免纯黑。蓝色承担主要选择和交互,青绿色主要承担成功与可用状态,二者不得混用语义。 + ## 3. 设计令牌 令牌以 CSS 自定义属性实现。`:root` 提供浅色值,`[data-theme="dark"]` 覆盖深色值。组件样式不得新增只服务于单个页面的颜色、阴影、圆角或间距常量。 @@ -69,6 +71,14 @@ 浅色与深色具体值只在 `styles.css` 的主题根节点维护。状态组件必须同时显示文字或图标,不能仅靠颜色区分。 +表面与边框使用规则: + +- 浅色主题的阅读、编辑和页面主内容使用白色或接近白色的 `--surface-raised`;主侧栏使用更深一阶的冰蓝灰 `--surface-canvas`,顶栏使用弱于侧栏的次级表面。相邻区域必须可辨,但不能形成高饱和色块。 +- 深色主题从深海军蓝画布开始,以蓝灰表面逐层提亮。不同层级优先依靠表面亮度与语义边框区分,不使用纯黑底色或无边界的大面积同色区域。 +- 浅色侧栏中,导航与最近会话、最近会话与账户区之间的结构分隔线使用 `--border-default`。列表行之间或卡片内部的弱分隔仍使用 `--border-subtle`,不得为了增强结构而给每一项加重边框。 +- 控件边界、焦点环和选中边框必须达到至少 `3:1` 的非文本对比度;正文、状态色和弱文本分别遵守无障碍对比度要求。 +- 业务组件不得通过主题条件分支写原始颜色;新增视觉层级时先确认能否复用现有表面、边框和状态令牌。 + ### 3.2 间距令牌 采用 4 像素基准: @@ -112,12 +122,12 @@ | 令牌 | 值 | 用途 | | --- | --- | --- | -| `--radius-control` | `8px` | 输入框、按钮、菜单项 | -| `--radius-card` | `12px` | 卡片和面板 | +| `--radius-control` | `10px` | 输入框、按钮、菜单项 | +| `--radius-card` | `14px` | 卡片和面板 | | `--shadow-card` | 主题定义 | 卡片和选中分段控件 | | `--shadow-dialog` | 主题定义 | 对话框和浮层 | -普通卡片通过表面色和边框区分,不默认添加阴影。阴影只表示真实的浮层关系。不允许页面自行创建高于 `--z-dialog` 的层级。 +整体使用适度圆角:控件和卡片保持清晰、克制的几何轮廓,不使用胶囊化的大圆角替代信息层级。普通卡片通过表面色和边框区分,不默认添加阴影;输入区等需要从内容流中明确浮起的持续操作面板可以使用克制的 `--shadow-card`。菜单和对话框使用对应层级阴影,阴影只表示真实的浮层关系。不允许页面自行创建高于 `--z-dialog` 的层级。 ### 3.5 动效令牌 @@ -263,20 +273,40 @@ - 活动记录必须保留操作者、动作、对象、范围、结果和时间等审计语义,不用纯图标代替关键字段。 - 表格密度可以选择“默认”或“紧凑”,但同一页面不得混用。 -### 6.8 应用顶栏与全局菜单 +### 6.8 应用侧栏 + +主侧栏用于一级导航、最近会话和稳定的账户入口,必须通过表面、结构线和选中状态建立清楚但不过度装饰的层级。 + +- 浅色侧栏使用冰蓝灰表面,与白色主内容画布形成明确边界;深色侧栏使用比主画布略亮的蓝灰表面。 +- 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle`。 +- 当前导航项和当前会话必须同时使用至少三种信号中的两种:强调背景、可见边框、图标或文字强调。浅色主题的当前项优先使用更完整的蓝色选中表面和较高字重。 +- 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。 +- 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。 + +### 6.9 应用顶栏与全局操作 应用顶栏用于窗口级状态、侧栏开关和低频全局操作,不承担页面标题或主要导航。顶栏必须保持紧凑,不能与页面内容争夺注意力。 - 顶栏高度默认为 `58px`,图标按钮使用 `34px × 34px` 点击区域。 - Runtime 状态、同步状态等短标签使用 `--font-caption`,不得放大为正文标题。 -- 全局菜单项使用 `--font-body`,图标为 `14px`,单项高度为 `32px`。 -- 菜单标签使用短名称,例如“安全与 Runtime 设置”“使用帮助”,不得同时使用大字号、粗体和强调色。 -- 全局菜单宽度由最长标签决定,建议为 `180px` 至 `200px`;说明性长文放入目标页面,不放在菜单项中。 -- 顶栏只直接显示当前任务所需的高频操作。设置、帮助、关于和版本检查等低频操作进入同一个全局菜单。 +- 浅色与深色切换属于持续可用的窗口级操作,直接显示太阳或月亮图标,并通过可访问名称说明将切换到的主题。选择必须持久化,切换不得改变布局。 +- 顶栏只直接显示当前任务所需的高频操作。已有侧栏账户设置入口时,不再重复显示 Runtime/设置入口;使用帮助优先放在相关操作附近,而不是为单个帮助项创建“更多”菜单。 +- 只有存在至少两个无法由稳定入口承载的低频全局操作时才增加全局菜单,不为了容纳一个冗余入口而显示省略号按钮。 - 窄窗口下优先压缩状态标签并保留图标按钮,不隐藏窗口控制、当前范围或进行中的风险状态。 -- 菜单使用 `menu`、`menuitem` 语义,支持上下方向键、Home、End 和 Escape,关闭后焦点返回触发按钮。 +- 使用全局菜单时,菜单项使用 `--font-body`、`14px` 图标和约 `32px` 单项高度;标签使用短名称。菜单保留 `menu`、`menuitem` 语义,支持上下方向键、Home、End 和 Escape,关闭后焦点返回触发按钮。 -### 6.9 应用通知与就地反馈 +### 6.10 上下文单选菜单 + +模型、专家角色和工作模式属于同一输入上下文,其选择器必须共享结构、尺寸和菜单视觉,不能出现一个精细菜单与两个风格不一致的原生下拉框。 + +- 触发按钮复用统一的模型选择按钮样式,保持相同高度、圆角、边框、展开指示和焦点状态。 +- 菜单使用 `menu` 与 `menuitemradio` 语义,当前项同时显示选中标记和 `aria-checked`。选项可以包含一行简短说明,但标签和说明不得被截断到无法区分。 +- 支持上、下方向键、Home、End、Enter 或 Space、Escape;打开后焦点进入当前项,关闭后返回触发按钮。 +- 点击或聚焦菜单外部时关闭;同一输入区内的模型、专家和模式菜单互斥展开。 +- 不可用选项保持可读并说明原因,键盘导航不得停留在不可选择项上。 +- 仅在选项简单且不需要说明、禁用原因或一致菜单行为时使用原生 `select`。 + +### 6.11 应用通知与就地反馈 应用级通知统一进入全局通知视口,页面不得自行复制通知卡片或在内容流中长期堆放短期消息。 @@ -355,7 +385,8 @@ ## 10. 深色主题 - 深色主题通过语义令牌替换实现,不在组件中使用主题条件分支选择原始颜色。 -- 表面层级主要依靠亮度和边框区分,避免大面积纯黑与高亮白形成刺眼对比。 +- 主画布使用深海军蓝,侧栏、顶栏、输入区和浮层使用逐级提亮的蓝灰表面;表面层级主要依靠亮度和边框区分,避免大面积纯黑与高亮白形成刺眼对比。 +- 深色强调色使用明亮但不荧光的蓝色,成功状态使用青绿色。用户消息等大面积强调表面使用更深的实心蓝,确保反白文字舒适可读。 - 输入框、代码块、表格悬停、选中行、弹窗遮罩和滚动条必须分别检查深色值。 - 图片、图表和状态色在深色背景下保持可读。图表系列不能只靠色相区分,还应使用形状、线型或标签。 - 焦点环、危险文本和弱文本在两种主题下都满足对比度要求。 @@ -427,6 +458,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 使用 `reading` 壳层,消息流与输入区共享宽度。 - 对话标题和当前项目范围位于 `PageHeader` 或对话上下文区,不在消息流中重复。 - 模式、模型或工具权限属于上下文控制,不与页面导航页签混用。 +- 模型、专家角色和工作模式使用统一的上下文单选菜单,并保持菜单互斥、键盘可达和选中状态明确。 +- “Enter 发送 · Shift+Enter 换行 · 附件仅在选择后发送”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。 +- 输入操作提示不能替代表单的可访问名称,输入框始终保留持久的程序化标签。 - 空对话展示可执行的起始建议,发送失败保留输入并提供重试。 ### 13.2 最近对话 @@ -466,6 +500,14 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。 - 标题或正文校验、删除确认、同步进度和可就地恢复的错误仍靠近对应编辑器或操作呈现。 +### 13.7 设置中心 + +- 全页设置使用固定标题区、左侧分类导航和独立滚动的内容区。右上角关闭按钮是离开设置中心的稳定入口。 +- 全页设置标题区依靠留白与内容区分层,不在标题下方绘制贯穿整个工作区的分隔线;模态设置可以保留标题边界。 +- 仅包含“关闭”的模态框页脚不得出现在全页设置中,避免重复关闭入口和没有功能意义的整宽分隔线。 +- 当前分类存在“保存”“测试”或“取消”等未提交配置操作时,可以显示操作页脚;页脚分隔线只用于界定这些持续操作。 +- 自动生效的设置不为了显示成功文案而长期占用页脚,通过应用通知提供短期反馈。加载或保存错误仍需保留可处理的上下文。 + ## 14. 文案规则 - 使用简体中文,动词直接、对象明确。 @@ -480,9 +522,11 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 ### 15.1 基础层 - [ ] 建立浅色与深色语义颜色令牌,移除业务组件中的原始颜色值。 +- [ ] 建立白色浅色主画布、冰蓝灰侧栏与深海军蓝深色表面的稳定层级。 - [ ] 建立间距、字体、圆角、阴影、层级和动效令牌。 - [ ] 为主题切换、减少动态效果和原生控件设置全局规则。 - [ ] 建立组件交互状态和焦点环基线。 +- [ ] 验证浅色侧栏结构分隔线与导航、会话选中状态清晰可辨。 ### 15.2 页面壳层与层级 @@ -502,14 +546,17 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - [ ] 实现 `ScopeBadge` 并覆盖全局、项目、失效和可切换状态。 - [ ] 实现 `EmptyState` 的首次为空、无结果、失败和只读变体。 - [ ] 实现 `danger-ghost`、`danger-solid` 和 `danger-zone`。 +- [ ] 统一模型、专家角色和工作模式的单选菜单结构、视觉与键盘行为。 ### 15.4 页面迁移 - [ ] 聊天迁移到 `reading`,统一消息流与输入区宽度。 +- [ ] 将输入快捷键与附件提示置于空输入框内部,输入区下方保持单行说明。 - [ ] 最近对话迁移到 `standard`,统一搜索、范围、时间和删除行为。 - [ ] 知识库迁移到 `master-detail`,清除内联浅色样式并补齐窄窗口单面板流程。 - [ ] 智能心跳迁移到 `dashboard`,统一状态卡片、配置和运行历史层级。 - [ ] 任务迁移到 `standard`,活动记录迁移到 `dashboard`,统一导航、筛选和表格行为。 +- [ ] 全页设置移除只含关闭操作的重复页脚,仅为未提交配置保留操作页脚。 ### 15.5 验收 diff --git a/demoui.html b/demoui.html new file mode 100644 index 0000000..f826a4d --- /dev/null +++ b/demoui.html @@ -0,0 +1,1221 @@ + + + + + + GoodBuddy 主题配色 Demo + + + +
+ + +
+
+
+ 产品官网维护 + 最后更新于今天 16:24 +
+ 项目:GoodBuddy 官网 +
+ 计划模式 + +
+
+ +
+
+
+
+
+ 检查官网内容与下载入口是否需要更新。 +
+
+ +
+
+ +
+
+
+ GoodBuddy + 刚刚 +
+

+ 我会先核对发布清单与项目知识,再给出不执行变更的更新计划。 +

+ +
+ + + + + 读取项目知识 + 范围:GoodBuddy 官网 · 8 个相关条目 + + 已完成 +
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+
+ + + + diff --git a/sites/README.md b/sites/README.md index 6cf57ed..09f1822 100644 --- a/sites/README.md +++ b/sites/README.md @@ -17,33 +17,27 @@ python -m http.server 4173 --bind 127.0.0.1 --directory sites ```powershell node sites/scripts/validate.mjs node --check sites/app.js -node --check sites/site.config.js ``` -校验脚本会检查必需文件、页内链接、本地资源、关键产品文案、主题与响应式规则,以及未发布状态下的下载链接保护。 +校验脚本会检查必需文件、页内链接、本地资源、关键产品文案、主题与响应式规则,以及下载入口是否始终指向官方最新 Release。 -## Release 配置 +## 下载入口 -当前版本的 Release 地址集中在 `site.config.js`,版本号必须与根目录 -`package.json` 保持一致: +官网正文不展示具体版本号,所有下载入口直接指向 GitHub 最新正式 +Release: -```js -window.GOODBUDDY_SITE_CONFIG = Object.freeze({ - version: "0.8.1", - releasePublished: true, - releaseUrl: "https://github.com/mesalogo/goodbuddy/releases/tag/v0.8.1", -}); +```text +https://github.com/mesalogo/goodbuddy/releases/latest ``` -准备尚未发布的版本时,将 `releasePublished` 暂时设为 `false`;正式 -Release 确认发布后改回 `true`,页面上的下载入口才会指向 Release -页面。官网不配置或猜测具体安装资产名称。 +新版本发布后 GitHub 会自动更新该地址的目标,官网无需同步修改版本号 +或安装资产名称。用户在 Release 页面按系统与架构选择文件并核对 +SHA-256 清单。 ## 文件 - `index.html`:页面结构与简体中文内容 - `styles.css`:语义令牌、浅深主题、焦点与响应式布局 -- `app.js`:主题、移动导航、当前章节和 Release 状态 -- `site.config.js`:版本与未来 Release 地址 +- `app.js`:主题、移动导航和当前章节 - `assets/favicon.svg`:站点图标 - `scripts/validate.mjs`:无依赖静态检查 diff --git a/sites/app.js b/sites/app.js index 748a32f..672b239 100644 --- a/sites/app.js +++ b/sites/app.js @@ -8,7 +8,6 @@ const themeToggle = document.querySelector("[data-theme-toggle]"); const themeColor = document.querySelector('meta[name="theme-color"]'); const systemTheme = window.matchMedia("(prefers-color-scheme: dark)"); - const config = window.GOODBUDDY_SITE_CONFIG; const getSavedTheme = () => { try { @@ -46,41 +45,7 @@ header?.classList.toggle("is-scrolled", window.scrollY > 12); }; - const configureReleaseLinks = () => { - const releaseLinks = document.querySelectorAll("[data-release-link]"); - const hasValidVersion = - typeof config?.version === "string" && - /^\d+\.\d+\.\d+$/.test(config.version); - const expectedReleaseUrl = hasValidVersion - ? `https://github.com/mesalogo/goodbuddy/releases/tag/v${config.version}` - : ""; - const isReady = - config?.releasePublished === true && - typeof config.releaseUrl === "string" && - config.releaseUrl === expectedReleaseUrl; - - releaseLinks.forEach((link) => { - if (!isReady) { - link.removeAttribute("href"); - link.removeAttribute("target"); - link.removeAttribute("rel"); - link.setAttribute("aria-disabled", "true"); - link.classList.add("is-disabled"); - link.textContent = "发布后开放"; - return; - } - - link.href = config.releaseUrl; - link.target = "_blank"; - link.rel = "noreferrer"; - link.removeAttribute("aria-disabled"); - link.classList.remove("is-disabled"); - link.innerHTML = `前往 v${config.version} Release(在新窗口打开)`; - }); - }; - applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light")); - configureReleaseLinks(); setHeaderState(); themeToggle?.addEventListener("click", () => { diff --git a/sites/index.html b/sites/index.html index 75003e5..e9f19e0 100644 --- a/sites/index.html +++ b/sites/index.html @@ -5,10 +5,10 @@ - GoodBuddy|安全可控的桌面智能助手 + GoodBuddy|桌面 AI 助手 diff --git a/sites/scripts/validate.mjs b/sites/scripts/validate.mjs index 01fda3f..fdc71df 100644 --- a/sites/scripts/validate.mjs +++ b/sites/scripts/validate.mjs @@ -3,14 +3,12 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const siteRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const repositoryRoot = path.resolve(siteRoot, ".."); const errors = []; const requiredFiles = [ "index.html", "styles.css", "app.js", - "site.config.js", "assets/favicon.svg", "README.md", ]; @@ -41,26 +39,16 @@ await Promise.all( }), ); -const [html, css, appJs, configJs] = await Promise.all([ +const [html, css, appJs] = await Promise.all([ readSiteFile("index.html"), readSiteFile("styles.css"), readSiteFile("app.js"), - readSiteFile("site.config.js"), ]); -let packageVersion = ""; -try { - packageVersion = JSON.parse( - await readFile(path.join(repositoryRoot, "package.json"), "utf8"), - ).version; -} catch { - errors.push("无法读取 package.json 版本"); -} for (const [relativePath, content] of [ ["index.html", html], ["styles.css", css], ["app.js", appJs], - ["site.config.js", configJs], ]) { report(!/[ \t]+$/m.test(content), `${relativePath} 包含行尾空白`); report(!content.includes("\t"), `${relativePath} 包含 Tab 缩进`); @@ -81,43 +69,39 @@ for (const breakpoint of ["1199px", "959px", "719px"]) { } const requiredCopy = [ - "Subagent 与智能路由", - "钉钉与企业微信以开发者预览提供", - "个人微信处于实验性边界", + "在本地管理笔记和待办", + "微信、企业微信和钉钉", + "单条消息最多 4 个附件", + "OpenCode 与 Continue", "单次最多添加 8 个附件,支持同时传入 5 张图片", "auto、low、medium、high", - "当前按单张结果呈现,不承诺批量多图生成", - "发布后开放", - "安全不是开关", + "下载入口始终指向最新正式 Release", + "主要安全边界", ]; for (const copy of requiredCopy) { report(html.includes(copy), `缺少准确文案:${copy}`); } +const htmlWithoutSvg = html.replace(//g, ""); report( - configJs.includes(`version: "${packageVersion}"`), - `site.config.js 版本必须与 package.json 的 ${packageVersion} 一致`, -); -report( - /releasePublished:\s*true/.test(configJs), - `v${packageVersion} Release 发布后 releasePublished 必须为 true`, -); -report( - configJs.includes( - `releaseUrl: "https://github.com/mesalogo/goodbuddy/releases/tag/v${packageVersion}"`, - ), - `v${packageVersion} Release URL 配置不正确`, -); -report( - appJs.includes("config?.releasePublished === true"), - "下载链接必须受 releasePublished 配置保护", -); -report( - appJs.includes("config.releaseUrl === expectedReleaseUrl"), - "下载链接必须与配置版本对应的 GitHub Release 地址一致", + !/\bv?\d+\.\d+\.\d+\b/.test(htmlWithoutSvg), + "官网正文不得写入需要随发布更新的具体版本号", ); +const releaseLinks = [ + ...html.matchAll(/]*data-release-link)[^>]*>/g), +].map((match) => match[0]); +report(releaseLinks.length >= 5, "缺少完整的官方下载入口"); +for (const link of releaseLinks) { + report( + /href="https:\/\/github\.com\/mesalogo\/goodbuddy\/releases\/latest"/.test(link), + `下载入口必须指向官方最新 Release:${link}`, + ); + report(/target="_blank"/.test(link), `下载入口必须在新窗口打开:${link}`); + report(/rel="[^"]*noreferrer[^"]*"/.test(link), `下载入口缺少 noreferrer:${link}`); +} + const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]); const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index); report(duplicateIds.length === 0, `存在重复 id:${[...new Set(duplicateIds)].join(", ")}`); diff --git a/sites/site.config.js b/sites/site.config.js deleted file mode 100644 index 3260599..0000000 --- a/sites/site.config.js +++ /dev/null @@ -1,5 +0,0 @@ -window.GOODBUDDY_SITE_CONFIG = Object.freeze({ - version: "0.8.1", - releasePublished: true, - releaseUrl: "https://github.com/mesalogo/goodbuddy/releases/tag/v0.8.1", -}); diff --git a/src/main/ipc.ts b/src/main/ipc.ts index df88d4b..e7ba356 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -520,6 +520,20 @@ function getKnowledgeSnapshot( documentsById.get(item.documentId)?.title ?? '未知文档', excerpt: item.quote ?? '', location: item.location + })), + tasks: snapshot.tasks.map((task) => ({ + id: task.id, + libraryId: task.libraryId, + sourceId: task.sourceId, + documentId: task.documentId, + documentName: task.documentName, + kind: task.kind, + status: task.status, + progress: task.progress, + message: task.message, + createdAt: task.createdAt, + startedAt: task.startedAt, + completedAt: task.completedAt })) } } @@ -3515,12 +3529,22 @@ export function registerIpcHandlers( assertTrustedSender(event, window) const value = knowledgeUpdateLibrarySchema.parse(input) knowledgeService.database.updateKnowledgeBase(value.libraryId, { + name: value.name, + description: value.description, graphEnabled: value.graphEnabled, graphStrategy: value.graphStrategy }) } ) + ipcMain.handle( + ipcChannels.knowledgeReextractGraph, + async (event, input: unknown) => { + assertTrustedSender(event, window) + return knowledgeService.reextractGraph(knowledgeIdSchema.parse(input)) + } + ) + ipcMain.handle( ipcChannels.knowledgeSelectFiles, async (event, input: unknown) => { diff --git a/src/main/knowledge/graph-extractor.test.ts b/src/main/knowledge/graph-extractor.test.ts index 838c23b..947cba0 100644 --- a/src/main/knowledge/graph-extractor.test.ts +++ b/src/main/knowledge/graph-extractor.test.ts @@ -348,6 +348,28 @@ describe('extraction strategies', () => { ) }) + it('propagates model extraction failures for hybrid and model strategies', async () => { + const chunks = [{ id: 'fallback', content: '# Local Entity' }] + for (const strategy of ['hybrid', 'model'] as const) { + await expect( + extractKnowledgeGraph(chunks, { + strategy, + extractStructured: async () => { + throw new Error('模型未返回图谱内容') + } + }) + ).rejects.toThrow('模型未返回图谱内容') + } + await expect( + extractKnowledgeGraph(chunks, { + strategy: 'hybrid', + extractStructured: async () => { + return { invalid: true } + } + }) + ).rejects.toThrow() + }) + it('supports rules, model, and ask behavior without an implicit model call', async () => { const chunks = [{ id: 'strategy', content: '# Local Entity' }] const callback = vi.fn() @@ -359,14 +381,12 @@ describe('extraction strategies', () => { strategy: 'ask', extractStructured: callback }) - const unavailable = await extractKnowledgeGraph(chunks, { - strategy: 'model' - }) - expect(callback).not.toHaveBeenCalled() expect(rules.requiresModelApproval).toBe(false) expect(ask.requiresModelApproval).toBe(true) - expect(unavailable.warnings).toEqual(['Model extraction is unavailable']) + await expect( + extractKnowledgeGraph(chunks, { strategy: 'model' }) + ).rejects.toThrow('Model extraction is unavailable') }) it('honors cancellation before and after the injected model callback', async () => { diff --git a/src/main/knowledge/graph-extractor.ts b/src/main/knowledge/graph-extractor.ts index 649f248..0a0922a 100644 --- a/src/main/knowledge/graph-extractor.ts +++ b/src/main/knowledge/graph-extractor.ts @@ -679,12 +679,7 @@ export async function extractKnowledgeGraph( } } if (!options.extractStructured) { - return { - ...rules, - strategy, - requiresModelApproval: false, - warnings: ['Model extraction is unavailable'] - } + throw new Error('Model extraction is unavailable') } const output = await options.extractStructured( @@ -692,7 +687,11 @@ export async function extractKnowledgeGraph( options.signal ) throwIfAborted(options.signal) - const model = validateModelGraph(output, prepared) + const parsedOutput = parseModelOutput(output) + if (!modelEnvelopeSchema.safeParse(parsedOutput).success) { + throw new Error('模型返回的图谱结构无效') + } + const model = validateModelGraph(parsedOutput, prepared) const graph = strategy === 'hybrid' ? mergeKnowledgeGraphs(rules, model) : model return { diff --git a/src/main/knowledge/knowledge-database.ts b/src/main/knowledge/knowledge-database.ts index f9f3501..56b8d1f 100644 --- a/src/main/knowledge/knowledge-database.ts +++ b/src/main/knowledge/knowledge-database.ts @@ -905,6 +905,54 @@ export class KnowledgeDatabase { ) } + pruneUnreferencedGeneratedGraph(knowledgeBaseId: string): { + entities: number + relations: number + } { + const normalizedId = requiredString( + knowledgeBaseId, + 'knowledgeBaseId', + MAX_ID_LENGTH + ) + const database = this.requireDatabase() + let entities = 0 + let relations = 0 + this.transaction(database, () => { + relations = Number( + database + .prepare( + `DELETE FROM graph_relations + WHERE knowledge_base_id = ? + AND locked = 0 + AND NOT EXISTS ( + SELECT 1 FROM graph_evidence + WHERE relation_id = graph_relations.id + )` + ) + .run(normalizedId).changes + ) + entities = Number( + database + .prepare( + `DELETE FROM graph_entities + WHERE knowledge_base_id = ? + AND locked = 0 + AND NOT EXISTS ( + SELECT 1 FROM graph_evidence + WHERE entity_id = graph_entities.id + ) + AND NOT EXISTS ( + SELECT 1 FROM graph_relations + WHERE source_entity_id = graph_entities.id + OR target_entity_id = graph_entities.id + )` + ) + .run(normalizedId).changes + ) + }) + return { entities, relations } + } + listChunks(documentId: string, limit = MAX_LIST_LIMIT): Chunk[] { const normalizedId = requiredString( documentId, diff --git a/src/main/knowledge/knowledge-service.test.ts b/src/main/knowledge/knowledge-service.test.ts index 4aad66a..1d4b571 100644 --- a/src/main/knowledge/knowledge-service.test.ts +++ b/src/main/knowledge/knowledge-service.test.ts @@ -8,6 +8,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ExtractStructured } from './graph-extractor' import { KnowledgeService } from './knowledge-service' import type { EmbeddingProvider } from './types' import { UrlImporter } from './url-importer' @@ -17,7 +18,8 @@ const services: KnowledgeService[] = [] async function createService( urlImporter?: UrlImporter, - embeddingProvider?: EmbeddingProvider + embeddingProvider?: EmbeddingProvider, + extractStructured?: ExtractStructured ): Promise<{ directory: string; service: KnowledgeService }> { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-knowledge-service-')) temporaryDirectories.push(directory) @@ -25,7 +27,8 @@ async function createService( databasePath: join(directory, 'knowledge.sqlite'), managedRoot: join(directory, 'managed'), urlImporter, - embeddingProvider + embeddingProvider, + extractStructured }) await service.initialize() services.push(service) @@ -145,9 +148,101 @@ describe('KnowledgeService', () => { expect(snapshot.entities.length).toBeGreaterThan(0) expect(snapshot.evidence.length).toBeGreaterThan(0) + expect(snapshot.tasks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'parsing', + status: 'succeeded', + progress: 100 + }), + expect.objectContaining({ + kind: 'embedding', + status: 'skipped', + progress: 100 + }), + expect.objectContaining({ + kind: 'graph', + status: 'succeeded', + progress: 100 + }) + ]) + ) await service.dispose() }) + it('reextracts graph evidence and removes only stale generated entities', async () => { + const { directory, service } = await createService() + const sourcePath = join(directory, 'reextract.md') + await writeFile( + sourcePath, + 'GoodBuddy(产品)依赖 Electron(框架)。', + 'utf8' + ) + const library = service.createLibrary({ + name: '重新抽取', + storageMode: 'reference', + graphEnabled: true, + graphStrategy: 'rules' + }) + await service.importPaths(library.id, [sourcePath]) + const stale = service.database.createEntity({ + knowledgeBaseId: library.id, + name: '过期实体', + type: '概念', + locked: false + }) + const manual = service.database.createEntity({ + knowledgeBaseId: library.id, + name: '人工实体', + type: '概念', + locked: true + }) + + await service.reextractGraph(library.id) + + const snapshot = service.snapshot(library.id) + expect(snapshot.evidence.length).toBeGreaterThan(0) + expect(service.database.getEntity(stale.id)).toBeUndefined() + expect(service.database.getEntity(manual.id)).toBeDefined() + }) + + it('fails hybrid reextraction when model extraction fails', async () => { + const extractStructured = vi.fn(async () => { + throw new Error('模型未返回图谱内容') + }) + const { directory, service } = await createService( + undefined, + undefined, + extractStructured + ) + const sourcePath = join(directory, 'hybrid-fallback.md') + await writeFile(sourcePath, '# 本地实体', 'utf8') + const library = service.createLibrary({ + name: '混合抽取', + storageMode: 'reference', + graphEnabled: false, + graphStrategy: 'hybrid' + }) + await service.importPaths(library.id, [sourcePath]) + service.database.updateKnowledgeBase(library.id, { + graphEnabled: true + }) + + await expect(service.reextractGraph(library.id)).rejects.toThrow( + '模型未返回图谱内容' + ) + expect(service.snapshot(library.id).entities).toHaveLength(0) + expect(service.snapshot(library.id).tasks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'graph', + status: 'failed', + message: '模型未返回图谱内容' + }) + ]) + ) + }) + it('indexes optional embeddings and performs vector-backed hybrid search', async () => { const provider: EmbeddingProvider = { provider: 'test-provider', diff --git a/src/main/knowledge/knowledge-service.ts b/src/main/knowledge/knowledge-service.ts index 8de9f01..6f7d929 100644 --- a/src/main/knowledge/knowledge-service.ts +++ b/src/main/knowledge/knowledge-service.ts @@ -23,7 +23,8 @@ import { classifyEmbeddingError } from './embedding-errors' import { extractKnowledgeGraph, normalizeEntityAlias, - type ExtractStructured + type ExtractStructured, + type GraphExtractionResult } from './graph-extractor' import { KnowledgeDatabase } from './knowledge-database' import type { @@ -66,6 +67,21 @@ export type KnowledgeDocumentSnapshot = Document & { error?: string } +export type KnowledgeTaskSnapshot = { + id: string + libraryId: string + sourceId?: string + documentId?: string + documentName: string + kind: 'parsing' | 'embedding' | 'graph' + status: 'queued' | 'running' | 'succeeded' | 'failed' | 'skipped' + progress: number + message?: string + createdAt: string + startedAt?: string + completedAt?: string +} + export type KnowledgeSnapshot = { libraries: KnowledgeLibrarySnapshot[] sources: KnowledgeSourceSnapshot[] @@ -73,6 +89,7 @@ export type KnowledgeSnapshot = { entities: GraphEntity[] relations: GraphRelation[] evidence: ReturnType + tasks: KnowledgeTaskSnapshot[] } export type KnowledgeServiceOptions = { @@ -89,6 +106,7 @@ const maximumFileBytes = 20 * 1024 * 1024 const maximumSourceBytes = 500 * 1024 * 1024 const maximumFilesPerSource = 2_000 const maximumEmbeddingChunksPerBatch = 32 +const maximumKnowledgeTasks = 500 function isInside(root: string, candidate: string): boolean { const path = relative(resolve(root), resolve(candidate)) @@ -105,6 +123,7 @@ export class KnowledgeService { private readonly watchers = new Map() private readonly syncTimers = new Map>() private readonly activeSyncs = new Map>() + private readonly tasks = new Map() private readonly lifecycleController = new AbortController() constructor(options: KnowledgeServiceOptions) { @@ -163,6 +182,109 @@ export class KnowledgeService { return Promise.resolve() } + private createKnowledgeTask(input: { + libraryId: string + sourceId?: string + documentId?: string + documentName: string + kind: KnowledgeTaskSnapshot['kind'] + status?: KnowledgeTaskSnapshot['status'] + message?: string + }): KnowledgeTaskSnapshot { + while (this.tasks.size >= maximumKnowledgeTasks) { + const oldestTaskId = this.tasks.keys().next().value as + | string + | undefined + if (!oldestTaskId) { + break + } + this.tasks.delete(oldestTaskId) + } + const now = new Date().toISOString() + const status = input.status ?? 'queued' + const task: KnowledgeTaskSnapshot = { + id: randomUUID(), + libraryId: input.libraryId, + sourceId: input.sourceId, + documentId: input.documentId, + documentName: input.documentName.slice(0, 512), + kind: input.kind, + status, + progress: status === 'succeeded' || status === 'skipped' ? 100 : 0, + message: input.message?.slice(0, 1_000), + createdAt: now, + startedAt: status === 'running' ? now : undefined, + completedAt: + status === 'succeeded' || + status === 'failed' || + status === 'skipped' + ? now + : undefined + } + this.tasks.set(task.id, task) + return task + } + + private updateKnowledgeTask( + taskId: string, + update: { + status?: KnowledgeTaskSnapshot['status'] + progress?: number + message?: string + documentId?: string + documentName?: string + } + ): void { + const current = this.tasks.get(taskId) + if (!current) { + return + } + const status = update.status ?? current.status + const terminal = + status === 'succeeded' || + status === 'failed' || + status === 'skipped' + this.tasks.set(taskId, { + ...current, + status, + documentId: update.documentId ?? current.documentId, + documentName: + update.documentName?.slice(0, 512) ?? current.documentName, + progress: + status === 'succeeded' || status === 'skipped' + ? 100 + : update.progress === undefined + ? current.progress + : Math.max(0, Math.min(100, Math.round(update.progress))), + message: + update.message === undefined + ? current.message + : update.message.slice(0, 1_000), + startedAt: + status === 'running' && !current.startedAt + ? new Date().toISOString() + : current.startedAt, + completedAt: + terminal && !current.completedAt + ? new Date().toISOString() + : current.completedAt + }) + } + + private failKnowledgeTask(taskId: string, error: unknown): void { + const current = this.tasks.get(taskId) + if ( + current?.status === 'succeeded' || + current?.status === 'skipped' + ) { + return + } + this.updateKnowledgeTask(taskId, { + status: 'failed', + message: error instanceof Error ? error.message : '任务失败' + }) + } + createLibrary(input: CreateKnowledgeBaseInput): KnowledgeBase { return this.database.createKnowledgeBase(input) } @@ -175,6 +297,11 @@ export class KnowledgeService { for (const source of this.database.listSources(id)) { this.stopWatcher(source.id) } + for (const task of this.tasks.values()) { + if (task.libraryId === id) { + this.tasks.delete(task.id) + } + } const deleted = this.database.deleteKnowledgeBase(id) if (deleted && library.storageMode === 'managed') { const path = join(this.managedRoot, id) @@ -206,7 +333,8 @@ export class KnowledgeService { documents: [], entities: [], relations: [], - evidence: [] + evidence: [], + tasks: [] } } const sources = this.database.listSources(libraryId).map((source) => ({ @@ -253,7 +381,12 @@ export class KnowledgeService { documents, entities: this.database.listEntities(libraryId), relations: this.database.listRelations(libraryId), - evidence: this.database.listEvidence(libraryId) + evidence: this.database.listEvidence(libraryId), + tasks: [...this.tasks.values()] + .filter((task) => task.libraryId === libraryId) + .sort((left, right) => + right.createdAt.localeCompare(left.createdAt) + ) } } @@ -427,7 +560,28 @@ export class KnowledgeService { this.lifecycleController.signal, AbortSignal.timeout(60_000) ]) - const result = await this.urlImporter.import(input, effectiveSignal) + const parsingTask = this.createKnowledgeTask({ + libraryId: library.id, + sourceId, + documentName: new URL(input).hostname, + kind: 'parsing' + }) + let result: Awaited> + try { + this.updateKnowledgeTask(parsingTask.id, { + status: 'running', + progress: 10, + message: '正在抓取并解析网页' + }) + result = await this.urlImporter.import(input, effectiveSignal) + this.updateKnowledgeTask(parsingTask.id, { + progress: 70, + message: '正在保存网页内容' + }) + } catch (error) { + this.failKnowledgeTask(parsingTask.id, error) + throw error + } let source = this.database.upsertSource({ id: sourceId, knowledgeBaseId, @@ -465,6 +619,12 @@ export class KnowledgeService { location: chunk.locator })) ) + this.updateKnowledgeTask(parsingTask.id, { + status: 'succeeded', + documentId: document.id, + documentName: document.title, + message: '网页解析完成' + }) await this.indexDocumentEmbeddings(document) await this.extractGraph(effectiveLibrary, document) source = this.database.upsertSource({ @@ -477,6 +637,7 @@ export class KnowledgeService { } }) } catch (error) { + this.failKnowledgeTask(parsingTask.id, error) this.database.upsertSource({ ...source, status: 'error', @@ -511,6 +672,62 @@ export class KnowledgeService { return this.syncSource(sourceId) } + async reextractGraph(knowledgeBaseId: string): Promise { + const library = this.requireLibrary(knowledgeBaseId) + if (!library.graphEnabled) { + throw new Error('请先启用知识图谱') + } + if (library.graphStrategy === 'ask') { + throw new Error('按需询问策略不会自动抽取,请在设置中选择其他策略') + } + const documents = this.database.listDocuments(library.id) + const tasks = documents.map((document) => + this.createKnowledgeTask({ + libraryId: library.id, + sourceId: document.sourceId, + documentId: document.id, + documentName: document.title, + kind: 'graph', + message: '等待重新抽取' + }) + ) + for (let index = 0; index < documents.length; index += 1) { + const document = documents[index] + const task = tasks[index] + if (!document || !task) { + continue + } + try { + this.updateKnowledgeTask(task.id, { + status: 'running', + progress: 10, + message: '正在重新抽取知识图谱' + }) + const result = await this.extractGraphResult(library, document) + this.updateKnowledgeTask(task.id, { + progress: 85, + message: '正在保存实体和关系' + }) + this.database.removeEvidenceForDocument(document.id) + this.storeExtractedGraph(library, document, result) + this.updateKnowledgeTask(task.id, { + status: 'succeeded', + message: `已抽取 ${result.entities.length} 个实体、${result.relations.length} 条关系` + }) + } catch (error) { + this.failKnowledgeTask(task.id, error) + for (const pendingTask of tasks.slice(index + 1)) { + this.updateKnowledgeTask(pendingTask.id, { + status: 'skipped', + message: '因前序图谱任务失败而未执行' + }) + } + throw error + } + } + this.database.pruneUnreferencedGeneratedGraph(library.id) + } + async removeSource(sourceId: string): Promise { const source = this.requireSource(sourceId) const library = this.requireLibrary(source.knowledgeBaseId) @@ -594,19 +811,42 @@ export class KnowledgeService { if (!file) { continue } + const parsingTask = this.createKnowledgeTask({ + libraryId: library.id, + sourceId: source.id, + documentName: file.relativePath, + kind: 'parsing' + }) try { + this.updateKnowledgeTask(parsingTask.id, { + status: 'running', + progress: 10, + message: '正在读取文档' + }) const buffer = await this.readBoundedFile(file.absolutePath) + this.updateKnowledgeTask(parsingTask.id, { + progress: 35, + message: '正在解析文档内容' + }) const checksum = createHash('sha256').update(buffer).digest('hex') const previous = existing.find( (document) => document.externalId === file.relativePath ) if (previous?.checksum === checksum) { + this.updateKnowledgeTask(parsingTask.id, { + status: 'skipped', + message: '文档内容未发生变化' + }) continue } const parsed = await parseDocument( basename(file.absolutePath), buffer ) + this.updateKnowledgeTask(parsingTask.id, { + progress: 75, + message: '正在保存解析结果' + }) const document = this.database.upsertDocument( { knowledgeBaseId: library.id, @@ -630,10 +870,17 @@ export class KnowledgeService { location: chunk.locator })) ) + this.updateKnowledgeTask(parsingTask.id, { + status: 'succeeded', + documentId: document.id, + documentName: document.title, + message: '文档解析完成' + }) this.database.removeEvidenceForDocument(document.id) await this.indexDocumentEmbeddings(document) await this.extractGraph(library, document) } catch (error) { + this.failKnowledgeTask(parsingTask.id, error) failures.push( `${file.relativePath}: ${ error instanceof Error ? error.message : '解析失败' @@ -661,10 +908,26 @@ export class KnowledgeService { requestedProvider?: EmbeddingProvider ): Promise { const provider = requestedProvider ?? this.embeddingProvider + const task = this.createKnowledgeTask({ + libraryId: document.knowledgeBaseId, + sourceId: document.sourceId, + documentId: document.id, + documentName: document.title, + kind: 'embedding' + }) if (!provider) { + this.updateKnowledgeTask(task.id, { + status: 'skipped', + message: '未启用向量化' + }) return } try { + this.updateKnowledgeTask(task.id, { + status: 'running', + progress: 5, + message: '正在准备文档分块' + }) const chunks = this.database.listChunks(document.id, 10_000) const embeddings: Array<{ chunkId: string @@ -704,8 +967,21 @@ export class KnowledgeService { vector }) } + this.updateKnowledgeTask(task.id, { + progress: + 5 + + ((offset + batch.length) / Math.max(chunks.length, 1)) * 85, + message: `正在向量化 ${Math.min( + offset + batch.length, + chunks.length + )}/${chunks.length} 个分块` + }) } if (this.embeddingProvider !== provider) { + this.updateKnowledgeTask(task.id, { + status: 'skipped', + message: '向量模型配置已变化' + }) return } this.database.replaceDocumentEmbeddings( @@ -714,11 +990,17 @@ export class KnowledgeService { provider.model, embeddings ) + this.updateKnowledgeTask(task.id, { + status: 'succeeded', + message: `已向量化 ${chunks.length} 个分块` + }) } catch (error) { if (this.lifecycleController.signal.aborted) { + this.failKnowledgeTask(task.id, new Error('向量化已取消')) return } const safeError = classifyEmbeddingError(error) + this.failKnowledgeTask(task.id, safeError) try { this.database.recordEmbeddingIndexError( document.id, @@ -736,11 +1018,55 @@ export class KnowledgeService { library: KnowledgeBase, document: Document ): Promise { - if (!library.graphEnabled || library.graphStrategy === 'ask') { + const task = this.createKnowledgeTask({ + libraryId: library.id, + sourceId: document.sourceId, + documentId: document.id, + documentName: document.title, + kind: 'graph' + }) + if (!library.graphEnabled) { + this.updateKnowledgeTask(task.id, { + status: 'skipped', + message: '知识图谱未启用' + }) return } + if (library.graphStrategy === 'ask') { + this.updateKnowledgeTask(task.id, { + status: 'skipped', + message: '按需询问策略不自动抽取' + }) + return + } + try { + this.updateKnowledgeTask(task.id, { + status: 'running', + progress: 10, + message: '正在准备图谱抽取' + }) + const result = await this.extractGraphResult(library, document) + this.updateKnowledgeTask(task.id, { + progress: 85, + message: '正在保存实体和关系' + }) + this.storeExtractedGraph(library, document, result) + this.updateKnowledgeTask(task.id, { + status: 'succeeded', + message: `已抽取 ${result.entities.length} 个实体、${result.relations.length} 条关系` + }) + } catch (error) { + this.failKnowledgeTask(task.id, error) + throw error + } + } + + private async extractGraphResult( + library: KnowledgeBase, + document: Document + ): Promise { const chunks = this.database.listChunks(document.id) - const result = await extractKnowledgeGraph( + return extractKnowledgeGraph( chunks.map((chunk) => ({ id: chunk.id, content: chunk.content @@ -750,6 +1076,13 @@ export class KnowledgeService { extractStructured: this.extractStructured } ) + } + + private storeExtractedGraph( + library: KnowledgeBase, + document: Document, + result: GraphExtractionResult + ): void { const existingEntities = this.database.listEntities(library.id) const entityIds = new Map() for (const entity of result.entities) { diff --git a/src/main/knowledge/model-extractor.test.ts b/src/main/knowledge/model-extractor.test.ts index 5c46b4f..ded04e1 100644 --- a/src/main/knowledge/model-extractor.test.ts +++ b/src/main/knowledge/model-extractor.test.ts @@ -65,7 +65,12 @@ describe('createModelGraphExtractor', () => { choices: [ { message: { - content: '```json\n{"relations":[]}\n```' + content: [ + { + type: 'text', + text: '```json\n{"relations":[]}\n```' + } + ] } } ] @@ -136,6 +141,22 @@ describe('createModelGraphExtractor', () => { ) }) + it('accepts top-level output text from compatible Responses providers', async () => { + const extract = createModelGraphExtractor( + store({ modelProtocol: 'openai-responses' }), + vi.fn(async () => + jsonResponse({ + output_text: '{"entities":[],"relations":[]}' + }) + ) + ) + + await expect(extract('extract this')).resolves.toEqual({ + entities: [], + relations: [] + }) + }) + it('requires a key only for API-key authentication', async () => { const extract = createModelGraphExtractor( store({ diff --git a/src/main/knowledge/model-extractor.ts b/src/main/knowledge/model-extractor.ts index f383a9b..20dac71 100644 --- a/src/main/knowledge/model-extractor.ts +++ b/src/main/knowledge/model-extractor.ts @@ -95,12 +95,28 @@ function openAIChatText(payload: unknown): string { if (!Array.isArray(choices)) { return '' } - const message = record(record(choices[0])?.message) - return typeof message?.content === 'string' ? message.content : '' + const choice = record(choices[0]) + const message = record(choice?.message) + if (typeof message?.content === 'string') { + return message.content + } + if (Array.isArray(message?.content)) { + return message.content + .flatMap((part) => { + const value = record(part) + return typeof value?.text === 'string' ? [value.text] : [] + }) + .join('') + } + return typeof choice?.text === 'string' ? choice.text : '' } function openAIResponsesText(payload: unknown): string { - const output = record(payload)?.output + const response = record(payload) + if (typeof response?.output_text === 'string') { + return response.output_text + } + const output = response?.output if (!Array.isArray(output)) { return '' } @@ -111,7 +127,7 @@ function openAIResponsesText(payload: unknown): string { }) .flatMap((part) => { const value = record(part) - return value?.type === 'output_text' && + return (value?.type === 'output_text' || value?.type === 'text') && typeof value.text === 'string' ? [value.text] : [] @@ -211,7 +227,9 @@ export function createModelGraphExtractor( ? openAIResponsesText(payload) : openAIChatText(payload) if (!text) { - throw new Error('模型未返回图谱内容') + throw new Error( + '模型未返回图谱内容,请重试或在知识库设置中切换到规则抽取' + ) } return extractJsonText(text) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 02ab487..58ce64d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -805,6 +805,11 @@ const desktopApi: DesktopApi = { libraryId ) }, + reextractGraph: (libraryId) => + ipcRenderer.invoke( + ipcChannels.knowledgeReextractGraph, + libraryId + ) as Promise, selectFiles: async (libraryId, graphStrategy) => { await ipcRenderer.invoke(ipcChannels.knowledgeSelectFiles, { libraryId, diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index df86e25..5b102a5 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -498,6 +498,7 @@ const api: DesktopApi = { })), updateLibrary: vi.fn(async () => {}), deleteLibrary: vi.fn(async () => {}), + reextractGraph: vi.fn(async () => {}), selectFiles: vi.fn(async () => {}), selectDirectory: vi.fn(async () => {}), importDroppedFiles: vi.fn(async () => {}), @@ -518,6 +519,35 @@ const api: DesktopApi = { } } +function composerMenuTrigger( + label: '专家角色' | '工作模式' +): HTMLButtonElement { + return screen.getByRole('button', { + name: new RegExp(`^${label}:`, 'u') + }) +} + +function openComposerMenu( + label: '专家角色' | '工作模式' +): HTMLElement { + fireEvent.click(composerMenuTrigger(label)) + return screen.getByRole('menu', { name: label }) +} + +function selectComposerOption( + label: '专家角色' | '工作模式', + optionLabel: string +): void { + const menu = openComposerMenu(label) + const option = within(menu) + .getByText(optionLabel, { selector: 'span' }) + .closest('button') + if (!option) { + throw new Error(`Missing ${label} option: ${optionLabel}`) + } + fireEvent.click(option) +} + describe('App', () => { beforeEach(() => { localStorage.clear() @@ -788,26 +818,28 @@ describe('App', () => { return } - expect(within(topbar).queryByLabelText('专家角色')).not.toBeInTheDocument() - expect(screen.getByLabelText('专家角色').closest('.composer')).not.toBeNull() + expect( + within(topbar).queryByRole('button', { + name: /^专家角色:/u + }) + ).not.toBeInTheDocument() + expect(composerMenuTrigger('专家角色').closest('.composer')).not.toBeNull() - const appMenuTrigger = within(topbar).getByLabelText('应用菜单') - fireEvent.click(appMenuTrigger) + const themeToggle = within(topbar).getByRole('button', { + name: '切换深色主题' + }) + fireEvent.click(themeToggle) + await waitFor(() => + expect(document.documentElement.dataset.theme).toBe('dark') + ) expect( screen.queryByRole('menuitem', { name: '重命名会话' }) ).not.toBeInTheDocument() expect( - screen.getByRole('menuitem', { name: '安全与 Runtime 设置' }) - ).toBeVisible() - await waitFor(() => - expect( - screen.getByRole('menuitem', { name: '安全与 Runtime 设置' }) - ).toHaveFocus() - ) - fireEvent.keyDown(document, { key: 'ArrowDown' }) - expect(screen.getByRole('menuitem', { name: '使用帮助' })).toHaveFocus() - fireEvent.keyDown(document, { key: 'Escape' }) - expect(appMenuTrigger).toHaveFocus() + within(topbar).getByRole('button', { + name: '切换浅色主题' + }) + ).toBe(themeToggle) expect(screen.queryByRole('menu')).not.toBeInTheDocument() const conversationMenuTrigger = within( @@ -1831,7 +1863,9 @@ describe('App', () => { expect( screen.getByRole('heading', { level: 1, name: '任务与活动' }) ).toBeInTheDocument() - expect(screen.queryByLabelText('专家角色')).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /^专家角色:/u }) + ).not.toBeInTheDocument() expect( screen.queryByLabelText('切换助手工作栏') ).not.toBeInTheDocument() @@ -1884,11 +1918,14 @@ describe('App', () => { it('offers only Ask and Execute in visible work mode controls', async () => { render() - const mode = await screen.findByLabelText('工作模式') + await screen.findByRole('button', { + name: '工作模式:Ask · 只读问答' + }) + const modeMenu = openComposerMenu('工作模式') expect( - within(mode) - .getAllByRole('option') - .map((option) => option.textContent) + within(modeMenu) + .getAllByRole('menuitemradio') + .map((option) => option.querySelector('span')?.textContent) ).toEqual(['Ask · 只读问答', 'Execute · 受控执行']) fireEvent.click(screen.getByLabelText('新建项目')) @@ -1904,6 +1941,46 @@ describe('App', () => { expect(screen.queryByRole('option', { name: /Plan/u })).toBeNull() }) + it('matches expert and work mode keyboard menus to the model picker', async () => { + render() + + const expertTrigger = await screen.findByRole('button', { + name: '专家角色:通用助手' + }) + expect(expertTrigger).toHaveClass('model-button') + fireEvent.keyDown(expertTrigger, { key: 'ArrowDown' }) + + const expertMenu = screen.getByRole('menu', { + name: '专家角色' + }) + expect(expertMenu).toHaveClass('runtime-picker__menu') + const generalExpert = within(expertMenu).getByRole( + 'menuitemradio', + { name: /^通用助手/u } + ) + const expertTeam = within(expertMenu).getByRole( + 'menuitemradio', + { name: /^专家团队(并行)/u } + ) + await waitFor(() => expect(generalExpert).toHaveFocus()) + fireEvent.keyDown(generalExpert, { key: 'ArrowDown' }) + expect(expertTeam).toHaveFocus() + fireEvent.keyDown(expertTeam, { key: 'Escape' }) + expect(expertTrigger).toHaveFocus() + expect( + screen.queryByRole('menu', { name: '专家角色' }) + ).not.toBeInTheDocument() + + const modeTrigger = composerMenuTrigger('工作模式') + fireEvent.click(modeTrigger) + const modeMenu = screen.getByRole('menu', { name: '工作模式' }) + expect(modeMenu).toHaveClass('runtime-picker__menu') + fireEvent.pointerDown(screen.getByLabelText('向 GoodBuddy 提问')) + expect( + screen.queryByRole('menu', { name: '工作模式' }) + ).not.toBeInTheDocument() + }) + it('groups composer tools and exposes clear control descriptions', async () => { render() @@ -1930,11 +2007,19 @@ describe('App', () => { { name: '对话设置' } ) expect( - within(conversationSettings).getByLabelText('专家角色') + within(conversationSettings).getByRole('button', { + name: '专家角色:通用助手' + }) ).toBeInTheDocument() expect( - within(conversationSettings).getByLabelText('工作模式') + within(conversationSettings).getByRole('button', { + name: '工作模式:Ask · 只读问答' + }) ).toBeInTheDocument() + expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute( + 'placeholder', + '给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · 附件仅在选择后发送' + ) expect( within(conversationSettings).getByRole('button', { name: /默认模型/u @@ -1954,8 +2039,10 @@ describe('App', () => { ]) render() - const mode = await screen.findByLabelText('工作模式') - expect(mode).toHaveValue('ask') + const mode = await screen.findByRole('button', { + name: '工作模式:Ask · 只读问答' + }) + expect(mode).toBeEnabled() fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: '制定发布方案' } }) @@ -1993,7 +2080,11 @@ describe('App', () => { expect(await screen.findByLabelText('当前项目')).toHaveValue( secondProject.id ) - expect(screen.getByLabelText('工作模式')).toHaveValue('execute') + expect( + screen.getByRole('button', { + name: '工作模式:Execute · 受控执行' + }) + ).toBeEnabled() fireEvent.change(screen.getByLabelText('当前项目'), { target: { value: project.id } @@ -2151,8 +2242,9 @@ describe('App', () => { }) render() - const mode = await screen.findByLabelText('工作模式') - expect(mode).toHaveValue('ask') + const mode = await screen.findByRole('button', { + name: '工作模式:Ask · 只读问答' + }) expect(mode).toBeEnabled() expect(mode.closest('.composer')).not.toBeNull() expect( @@ -2160,7 +2252,10 @@ describe('App', () => { new RegExp(`${label} Ask 模式.*只允许搜索当前启用的知识库`) ) ).toBeInTheDocument() - fireEvent.change(mode, { target: { value: 'execute' } }) + selectComposerOption('工作模式', 'Execute · 受控执行') + expect(mode).toHaveAccessibleName( + '工作模式:Execute · 受控执行' + ) fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: '执行任务' } @@ -2203,11 +2298,14 @@ describe('App', () => { }) render() - const mode = await screen.findByLabelText('工作模式') - expect(mode).toHaveValue('ask') + const mode = await screen.findByRole('button', { + name: '工作模式:Ask · 只读问答' + }) expect(mode).toBeEnabled() - fireEvent.change(mode, { target: { value: 'execute' } }) - expect(mode).toHaveValue('execute') + selectComposerOption('工作模式', 'Execute · 受控执行') + expect(mode).toHaveAccessibleName( + '工作模式:Execute · 受控执行' + ) fireEvent.click(await screen.findByRole('button', { name: /OpenCode/u })) fireEvent.click( @@ -2217,7 +2315,7 @@ describe('App', () => { ) await waitFor(() => { - expect(mode).toHaveValue('ask') + expect(mode).toHaveAccessibleName('工作模式:Ask · 只读问答') expect(mode).toBeEnabled() }) }) @@ -2232,13 +2330,16 @@ describe('App', () => { }) render() - const mode = await screen.findByLabelText('工作模式') + const mode = await screen.findByRole('button', { + name: '工作模式:Ask · 只读问答' + }) + const modeMenu = openComposerMenu('工作模式') expect( - within(mode).getByRole('option', { - name: 'Execute · 受控执行' + within(modeMenu).getByRole('menuitemradio', { + name: /^Execute · 受控执行/u }) ).toBeDisabled() - expect(mode).toHaveValue('ask') + expect(mode).toHaveAccessibleName('工作模式:Ask · 只读问答') }) it('allows a direct model to submit Execute with GoodBuddy approvals', async () => { @@ -2251,8 +2352,10 @@ describe('App', () => { }) render() - const mode = await screen.findByLabelText('工作模式') - fireEvent.change(mode, { target: { value: 'execute' } }) + const mode = await screen.findByRole('button', { + name: '工作模式:Ask · 只读问答' + }) + selectComposerOption('工作模式', 'Execute · 受控执行') fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: '读取项目文件' } }) @@ -3116,7 +3219,7 @@ describe('App', () => { expect((await screen.findAllByText('生图')).length).toBeGreaterThan(0) expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute( 'placeholder', - '描述你想生成的图片…' + '描述你想生成的图片…\nEnter 发送 · Shift+Enter 换行 · 附件仅在选择后发送' ) await waitFor(() => expect(api.artifacts.list).toHaveBeenCalled() @@ -3195,9 +3298,7 @@ describe('App', () => { it('can dispatch a request to the parallel expert team', async () => { render() - fireEvent.change(screen.getByLabelText('专家角色'), { - target: { value: 'team' } - }) + selectComposerOption('专家角色', '专家团队(并行)') fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: '制定发布计划' } }) @@ -3260,10 +3361,14 @@ describe('App', () => { ]) render() - await screen.findByRole('option', { name: '发布专家' }) - fireEvent.change(screen.getByLabelText('专家角色'), { - target: { value: expertId } - }) + await waitFor(() => expect(api.experts.list).toHaveBeenCalled()) + const expertMenu = openComposerMenu('专家角色') + fireEvent.click( + (await within(expertMenu).findByText('发布专家', { + selector: 'span' + })) + .closest('button')! + ) fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: '检查发布方案' } }) @@ -4021,7 +4126,7 @@ describe('App', () => { screen.queryByLabelText('切换助手工作栏') ).not.toBeInTheDocument() expect( - screen.queryByLabelText('专家角色') + screen.queryByRole('button', { name: /^专家角色:/u }) ).not.toBeInTheDocument() }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 37c17c2..54f0545 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -19,6 +19,7 @@ import { Mic, Minimize2, Minus, + Moon, MoreHorizontal, Paperclip, PanelLeft, @@ -31,6 +32,7 @@ import { PanelsTopLeft, Sparkles, Square, + Sun, TerminalSquare, Trash2, UserRound, @@ -42,7 +44,8 @@ import { useMemo, useReducer, useRef, - useState + useState, + type ReactNode } from 'react' import type { ApprovalDecision, @@ -1032,6 +1035,186 @@ function WindowControls({ ) } +type ComposerMenuOption = { + value: T + label: string + description: string + disabled?: boolean +} + +function ComposerMenuSelect({ + ariaLabel, + className, + describedBy, + disabled = false, + icon, + menuOpen, + onChange, + onOpenChange, + options, + value +}: { + ariaLabel: string + className: string + describedBy?: string + disabled?: boolean + icon: ReactNode + menuOpen: boolean + onChange: (value: T) => void + onOpenChange: (open: boolean) => void + options: readonly ComposerMenuOption[] + value: T +}): React.JSX.Element { + const buttonRef = useRef(null) + const menuRef = useRef(null) + const selectedOption = + options.find((option) => option.value === value) ?? options[0] + + useEffect(() => { + if (!menuOpen) { + return + } + const menu = menuRef.current + if (!menu) { + return + } + const menuItems = Array.from( + menu.querySelectorAll('[role="menuitemradio"]') + ).filter((item) => !item.disabled) + const initialItem = + menuItems.find( + (item) => item.getAttribute('aria-checked') === 'true' + ) ?? menuItems[0] + menuItems.forEach((item) => { + item.tabIndex = item === initialItem ? 0 : -1 + }) + const focusFrame = requestAnimationFrame(() => { + initialItem?.focus() + }) + const isMenuTarget = (target: EventTarget | null): boolean => + target instanceof Node && + (menu.contains(target) || + buttonRef.current?.contains(target) === true) + const dismissOnOutsidePointer = (event: PointerEvent): void => { + if (!isMenuTarget(event.target)) { + onOpenChange(false) + } + } + const dismissOnOutsideFocus = (event: FocusEvent): void => { + if (!isMenuTarget(event.target)) { + onOpenChange(false) + } + } + document.addEventListener('pointerdown', dismissOnOutsidePointer) + document.addEventListener('focusin', dismissOnOutsideFocus) + return () => { + cancelAnimationFrame(focusFrame) + document.removeEventListener( + 'pointerdown', + dismissOnOutsidePointer + ) + document.removeEventListener('focusin', dismissOnOutsideFocus) + } + }, [menuOpen, onOpenChange, value]) + + return ( +
+ + {menuOpen && ( +
{ + const items = Array.from( + event.currentTarget.querySelectorAll( + '[role="menuitemradio"]' + ) + ).filter((item) => !item.disabled) + const currentIndex = items.indexOf( + document.activeElement as HTMLButtonElement + ) + let nextIndex: number | undefined + if (event.key === 'ArrowDown') { + nextIndex = (currentIndex + 1) % items.length + } else if (event.key === 'ArrowUp') { + nextIndex = + (currentIndex - 1 + items.length) % items.length + } else if (event.key === 'Home') { + nextIndex = 0 + } else if (event.key === 'End') { + nextIndex = items.length - 1 + } else if (event.key === 'Escape') { + event.preventDefault() + onOpenChange(false) + buttonRef.current?.focus() + } + const nextItem = + nextIndex === undefined ? undefined : items.at(nextIndex) + if (nextItem) { + event.preventDefault() + items.forEach((item) => { + item.tabIndex = item === nextItem ? 0 : -1 + }) + nextItem.focus() + } + }} + ref={menuRef} + role="menu" + > + {options.map((option) => ( + + ))} +
+ )} +
+ ) +} + function App(): React.JSX.Element { const [conversations, setConversations] = useState(loadConversations) const [activeId, setActiveId] = useState(() => conversations[0]?.id ?? '') @@ -1105,9 +1288,11 @@ function App(): React.JSX.Element { const [runtimeStatusKey, setRuntimeStatusKey] = useState('') const [runtimeSettings, setRuntimeSettings] = useState() const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false) + const [composerMenuOpen, setComposerMenuOpen] = useState< + 'expert' | 'mode' | undefined + >() const runtimeMenuButtonRef = useRef(null) const runtimeMenuRef = useRef(null) - const [topbarMenuOpen, setTopbarMenuOpen] = useState(false) const [runtimeSwitching, setRuntimeSwitching] = useState(false) const [appearanceTheme, setAppearanceTheme] = useState(loadAppearanceTheme) @@ -1120,12 +1305,67 @@ function App(): React.JSX.Element { appearanceTheme, systemPrefersDark ) + const toggleAppearanceTheme = useCallback((): void => { + setAppearanceTheme( + resolvedAppearanceTheme === 'dark' ? 'light' : 'dark' + ) + }, [resolvedAppearanceTheme]) const agentRuntimeSelected = isAgentRuntime(runtime) const effectiveWorkMode = workMode === 'execute' && runtime?.supportsToolExecution === false ? 'ask' : workMode + const setExpertMenuOpen = useCallback((open: boolean): void => { + setComposerMenuOpen(open ? 'expert' : undefined) + if (open) { + setRuntimeMenuOpen(false) + } + }, []) + const setModeMenuOpen = useCallback((open: boolean): void => { + setComposerMenuOpen(open ? 'mode' : undefined) + if (open) { + setRuntimeMenuOpen(false) + } + }, []) + const assistantExpertOptions = useMemo< + ComposerMenuOption[] + >( + () => [ + { + value: '', + label: '通用助手', + description: '默认单助手' + }, + { + value: 'team', + label: '专家团队(并行)', + description: '多个专家并行协作' + }, + ...assistantExperts.map((expert) => ({ + value: expert.id, + label: expert.name, + description: expert.description || '自定义专家角色' + })) + ], + [assistantExperts] + ) + const workModeOptions = useMemo< + ComposerMenuOption[] + >( + () => + interactiveWorkModes.map((value) => ({ + value, + label: workModeLabels[value], + description: + value === 'execute' + ? '通过审批后执行工具操作' + : '只读问答,不修改文件', + disabled: + value === 'execute' && !runtime?.supportsToolExecution + })), + [runtime?.supportsToolExecution] + ) const [appInfo, setAppInfo] = useState() const [narrowWindow, setNarrowWindow] = useState( () => window.innerWidth < 900 @@ -1193,10 +1433,12 @@ function App(): React.JSX.Element { documents: [], graphNodes: [], graphRelations: [], - evidence: [] + evidence: [], + tasks: [] }) const [knowledgeLoading, setKnowledgeLoading] = useState(true) const [knowledgeLoadError, setKnowledgeLoadError] = useState() + const [knowledgeOperationCount, setKnowledgeOperationCount] = useState(0) const knowledgeLoadRequestRef = useRef(0) const failedKnowledgeLibraryIdRef = useRef( undefined @@ -1216,8 +1458,6 @@ function App(): React.JSX.Element { const scrollRef = useRef(null) const sidebarRef = useRef(null) const sidebarToggleRef = useRef(null) - const topbarMenuRef = useRef(null) - const topbarMenuTriggerRef = useRef(null) const conversationActionTriggerRefs = useRef( new Map() ) @@ -1279,66 +1519,6 @@ function App(): React.JSX.Element { resizeComposerTextarea(inputRef.current) }, [input]) - useEffect(() => { - if (!topbarMenuOpen) { - return - } - const focusFrame = requestAnimationFrame(() => { - topbarMenuRef.current - ?.querySelector('[role="menuitem"]') - ?.focus() - }) - const closeOnOutsidePointer = (event: PointerEvent): void => { - if ( - event.target instanceof Node && - !topbarMenuRef.current?.contains(event.target) - ) { - setTopbarMenuOpen(false) - } - } - const handleMenuKeyDown = (event: KeyboardEvent): void => { - if (event.key === 'Escape') { - event.preventDefault() - setTopbarMenuOpen(false) - topbarMenuTriggerRef.current?.focus() - return - } - const menuItems = Array.from( - topbarMenuRef.current?.querySelectorAll( - '[role="menuitem"]' - ) ?? [] - ) - if (menuItems.length === 0) { - return - } - const currentIndex = menuItems.indexOf( - document.activeElement as HTMLButtonElement - ) - const targetIndex = - event.key === 'Home' - ? 0 - : event.key === 'End' - ? menuItems.length - 1 - : event.key === 'ArrowDown' - ? (currentIndex + 1) % menuItems.length - : event.key === 'ArrowUp' - ? (currentIndex - 1 + menuItems.length) % - menuItems.length - : -1 - if (targetIndex >= 0) { - event.preventDefault() - menuItems[targetIndex]?.focus() - } - } - document.addEventListener('pointerdown', closeOnOutsidePointer) - document.addEventListener('keydown', handleMenuKeyDown) - return () => { - cancelAnimationFrame(focusFrame) - document.removeEventListener('pointerdown', closeOnOutsidePointer) - document.removeEventListener('keydown', handleMenuKeyDown) - } - }, [topbarMenuOpen]) - useEffect(() => { saveAppearanceTheme(appearanceTheme) }, [appearanceTheme]) @@ -2999,6 +3179,25 @@ function App(): React.JSX.Element { return () => clearTimeout(timeout) }, [refreshKnowledge]) + useEffect(() => { + if (view !== 'knowledge' && knowledgeOperationCount === 0) { + return + } + const interval = setInterval(() => { + void refreshKnowledge( + knowledgeSnapshot.selectedLibraryId + ).catch(() => { + // The task center keeps the last successful snapshot while polling. + }) + }, knowledgeOperationCount > 0 ? 350 : 1_000) + return () => clearInterval(interval) + }, [ + knowledgeOperationCount, + knowledgeSnapshot.selectedLibraryId, + refreshKnowledge, + view + ]) + useEffect(() => { void Promise.all([ window.goodbuddy.settings.getRuntime(), @@ -4078,11 +4277,20 @@ function App(): React.JSX.Element { await refreshKnowledge() } - const runKnowledgeSourceAction = async ( - action: () => Promise - ): Promise => { - await action() - await refreshSelectedKnowledge() + const runKnowledgeSourceAction = async ( + action: () => Promise + ): Promise => { + setKnowledgeOperationCount((count) => count + 1) + try { + const result = await action() + await refreshSelectedKnowledge() + return result + } catch (error) { + await refreshSelectedKnowledge().catch(() => undefined) + throw error + } finally { + setKnowledgeOperationCount((count) => Math.max(0, count - 1)) + } } const openActivityConversation = (conversationId: string): void => { @@ -4627,55 +4835,28 @@ function App(): React.JSX.Element { )} -
- - {topbarMenuOpen && ( -
- - -
+
+