feat: enhance knowledge workflows and refresh interface

This commit is contained in:
lofyer
2026-08-10 16:27:40 +08:00
parent 2cb712e4ba
commit 0fab985f28
26 changed files with 4088 additions and 1183 deletions
+58 -11
View File
@@ -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 验收
+1221
View File
File diff suppressed because it is too large Load Diff
+10 -16
View File
@@ -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`:无依赖静态检查
-35
View File
@@ -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<span class="sr-only">(在新窗口打开)</span>`;
});
};
applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light"));
configureReleaseLinks();
setHeaderState();
themeToggle?.addEventListener("click", () => {
+111 -90
View File
@@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="GoodBuddy 是安全可控的桌面智能助手与 Agent 工作空间。0.8.1 改进语音反馈、文档附件与桌面交互稳定性。"
content="GoodBuddy 是桌面 AI 助手,支持项目知识库、魔法笔记、远程消息通道和受控工具执行。"
/>
<meta name="theme-color" content="#f6f8fb" />
<title>GoodBuddy安全可控的桌面智能助手</title>
<title>GoodBuddy桌面 AI 助手</title>
<link rel="icon" href="./assets/favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="./styles.css" />
<script>
@@ -57,7 +57,7 @@
<nav class="site-navigation" id="site-navigation" aria-label="主导航" data-navigation>
<a href="#features">功能</a>
<a href="#release">0.8.0</a>
<a href="#release">亮点</a>
<a href="#download">下载</a>
<a href="#security">安全</a>
</nav>
@@ -91,33 +91,35 @@
<div class="hero-copy">
<div class="eyebrow">
<span class="status-dot" aria-hidden="true"></span>
GoodBuddy 0.8.1 已发布
桌面 AI 助手
</div>
<h1 id="hero-title">把 AI 放在桌面,<br /><span>也把控制权留在手中</span></h1>
<h1 id="hero-title">在桌面上使用 AI<br /><span>工作过程看得见</span></h1>
<p class="hero-lead">
GoodBuddy 是安全可控的桌面智能助手与 Agent 工作空间。连接模型、知识与工具
在清晰的范围和审批边界内完成真正的工作
GoodBuddy 可以连接模型、知识库和工具。知识按全局或项目管理
工具执行前可以确认,运行记录随时可查
</p>
<div class="hero-actions">
<a class="button button--primary" href="#release">查看 0.8.0 亮点</a>
<a class="button button--primary" href="#features">查看功能</a>
<a
class="button button--secondary is-disabled"
aria-disabled="true"
class="button button--secondary"
href="https://github.com/mesalogo/goodbuddy/releases/latest"
target="_blank"
rel="noreferrer"
data-release-link
>发布后开放</a>
>前往官方下载页<span class="sr-only">(在新窗口打开)</span></a>
</div>
<ul class="hero-facts" aria-label="产品特性概览">
<li>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
Windows / macOS / Linux
支持 Windows / macOS / Linux
</li>
<li>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
项目范围隔离
全局和项目知识分开管理
</li>
<li>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
工具调用可审批
工具执行前可确认
</li>
</ul>
</div>
@@ -145,6 +147,7 @@
</div>
<div class="side-item is-active"><span></span>对话</div>
<div class="side-item"><span></span>知识库</div>
<div class="side-item"><span></span>魔法笔记</div>
<div class="side-item"><span></span>智能心跳</div>
<div class="side-item"><span></span>任务与活动</div>
<div class="sidebar-spacer"></div>
@@ -153,24 +156,24 @@
<div class="app-content">
<div class="app-content-header">
<div>
<strong>产品发布准备</strong>
<span>项目:GoodBuddy 0.8.0</span>
<strong>产品官网维护</strong>
<span>项目:GoodBuddy 官网</span>
</div>
<div class="mode-pill">计划模式</div>
</div>
<div class="message-area">
<div class="message message--user">梳理 0.8.0 发布前还需要完成的工作</div>
<div class="message message--user">检查官网内容与下载入口是否需要更新</div>
<div class="message message--assistant">
<div class="assistant-label">
<span class="assistant-avatar">G</span>
<strong>GoodBuddy</strong>
</div>
<p>我会先核对发布清单与项目知识,再给出不执行变更的计划</p>
<p>我会先检查站点内容和发布页,不修改文件</p>
<div class="tool-card">
<div class="tool-icon">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h7" /></svg>
</div>
<div><strong>读取项目知识</strong><span>范围:GoodBuddy 0.8.0</span></div>
<div><strong>读取项目知识</strong><span>范围:GoodBuddy 官网</span></div>
<span class="tool-state">已完成</span>
</div>
<div class="plan-lines" aria-hidden="true"><span></span><span></span><span></span></div>
@@ -187,11 +190,11 @@
<span class="floating-icon">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 5 6v5c0 4.5 2.8 8.6 7 10 4.2-1.4 7-5.5 7-10V6l-7-3Z" /><path d="m9 12 2 2 4-4" /></svg>
</span>
<span><strong>执行前确认</strong><small>每次工具调用都清晰可见</small></span>
<span><strong>执行前确认</strong><small>查看工具名称和影响</small></span>
</div>
<div class="floating-card floating-card--scope">
<span class="scope-dot"></span>
<span><strong>项目范围</strong><small>上下文不会悄悄混用</small></span>
<span><strong>项目范围</strong><small>知识和任务按项目区分</small></span>
</div>
</div>
</div>
@@ -201,8 +204,8 @@
<div class="section-inner proof-grid">
<div><strong>3 种</strong><span>问答 / 计划 / 执行模式</span></div>
<div><strong>2 层</strong><span>全局与项目知识范围</span></div>
<div><strong>明确</strong><span>工具权限与活动记录</span></div>
<div><strong>跨平台</strong><span>x64 与 arm64</span></div>
<div><strong>可查看</strong><span>工具调用与活动记录</span></div>
<div><strong>6 组</strong><span>系统与架构组合</span></div>
</div>
</section>
@@ -210,11 +213,11 @@
<div class="section-inner">
<div class="section-heading">
<div>
<p class="kicker">围绕真实工作流设计</p>
<h2 id="features-title">不是另一个聊天窗口</h2>
<p class="kicker">主要功能</p>
<h2 id="features-title">GoodBuddy 可以做什么</h2>
</div>
<p>
从上下文组织到执行审批,每一步都让范围、状态和风险保持可见
管理对话和知识,运行任务,并在需要时调用经过确认的工具
</p>
</div>
@@ -227,8 +230,8 @@
</svg>
</div>
<span class="feature-number">01</span>
<h3>受控 Agent 运行</h3>
<p>问答计划模式在运行时保持只读;执行模式中的工具操作经过现有审批控制,并保留取消、超时输出边界</p>
<h3>Agent 运行模式</h3>
<p>问答计划模式不执行工具。执行模式通过审批控制调用工具,并支持取消、超时输出限制</p>
<div class="mode-row" aria-label="三种工作模式">
<span>问答 <small>只读</small></span>
<span>计划 <small>只读</small></span>
@@ -244,8 +247,8 @@
</svg>
</div>
<span class="feature-number">02</span>
<h3>有范围的知识</h3>
<p>区分全局与项目知识。搜索、引用和创建都围绕当前范围展开,让上下文来源清楚可追溯</p>
<h3>知识库按范围管理</h3>
<p>全局知识和项目知识分开保存。搜索结果和引用会显示来源</p>
</article>
<article class="feature-card">
@@ -256,8 +259,8 @@
</svg>
</div>
<span class="feature-number">03</span>
<h3>智能心跳与任务</h3>
<p>将周期计划、运行状态、结果活动记录放在同一条可检查的工作链路中</p>
<h3>定时任务和运行记录</h3>
<p>可以创建周期计划,查看每次运行状态、结果活动记录。</p>
</article>
<article class="feature-card">
@@ -267,8 +270,8 @@
</svg>
</div>
<span class="feature-number">04</span>
<h3>文档与图像输入</h3>
<p>单次最多添加 8 个附件,支持同时传入 5 张图片;在一个会话中汇集任务所需材料</p>
<h3>文档和图片</h3>
<p>单次最多添加 8 个附件,支持同时传入 5 张图片。</p>
</article>
<article class="feature-card">
@@ -280,8 +283,8 @@
</svg>
</div>
<span class="feature-number">05</span>
<h3>可控的图像生成</h3>
<p>生图质量支持 auto、low、medium、high 四档。结果以单张图像呈现,并作为本地工件保存</p>
<h3>生成图片</h3>
<p>支持 auto、low、medium、high 四档质量。生成结果会保存到本地</p>
</article>
<article class="feature-card feature-card--wide feature-card--accent">
@@ -292,8 +295,8 @@
</svg>
</div>
<span class="feature-number">06</span>
<h3>模型与工具,由你连接</h3>
<p>在桌面端管理模型配置、MCP 工具运行时。密钥留在主进程的加密设置存储中,不交给网页渲染层</p>
<h3>模型、MCP 与运行时</h3>
<p>模型连接、MCP 工具运行时都在桌面端配置。API 密钥只保存在主进程</p>
<div class="provider-pills" aria-label="支持的连接类型">
<span>模型提供商</span><span>MCP</span><span>OpenCode</span><span>Continue</span>
</div>
@@ -306,13 +309,13 @@
<div class="section-inner">
<div class="release-heading">
<div class="version-lockup" aria-hidden="true">
<span>VERSION</span>
<strong>0.8.0</strong>
<span>HIGHLIGHTS</span>
<strong>NOW</strong>
</div>
<div>
<p class="kicker">下一站</p>
<h2 id="release-title">0.8.0 更新亮点</h2>
<p>更聪明地组织工作,也更诚实地标注能力边界。以下功能状态以正式 Release 说明为准</p>
<p class="kicker">近期新增</p>
<h2 id="release-title">笔记、消息通道和运行时改进</h2>
<p>下面这些功能已经包含在当前正式版本中</p>
</div>
</div>
@@ -320,56 +323,56 @@
<li class="release-item">
<div class="release-index">01</div>
<div class="release-copy">
<div class="release-label">0.8.0</div>
<h3>Subagent 与智能路由</h3>
<p>面向复杂任务的协作与路由能力归入 0.8.0,不将仍在开发中的路径描述为当前稳定能力</p>
<div class="release-label">魔法笔记</div>
<h3>魔法笔记</h3>
<p>在本地管理笔记和待办,支持范围、筛选、富文本编辑和 AI 评论</p>
</div>
<div class="release-visual route-visual" aria-hidden="true">
<span class="route-node route-node--main">主任务</span>
<span class="route-node route-node--main">笔记</span>
<span class="route-line route-line--one"></span>
<span class="route-line route-line--two"></span>
<span class="route-node route-node--sub-one">研究</span>
<span class="route-node route-node--sub-two">验证</span>
<span class="route-node route-node--sub-one">待办</span>
<span class="route-node route-node--sub-two">AI 评论</span>
</div>
</li>
<li class="release-item">
<div class="release-index">02</div>
<div class="release-copy">
<div class="release-label release-label--preview">开发者预览</div>
<h3>IM 渠道接入</h3>
<p>钉钉与企业微信以开发者预览提供;个人微信处于实验性边界,不作为面向生产环境的稳定承诺</p>
<div class="release-label release-label--preview">远程通道</div>
<h3>微信、企业微信和钉钉</h3>
<p>每个消息通道使用独立会话和系统项目,并记录发送者范围、运行模式和活动</p>
</div>
<div class="release-visual channel-visual" aria-label="渠道状态">
<span><b>钉钉</b><small>开发者预览</small></span>
<span><b>企业微信</b><small>开发者预览</small></span>
<span class="is-experimental"><b>个人微信</b><small>实验性边界</small></span>
<span><b>钉钉</b><small>独立会话</small></span>
<span><b>企业微信</b><small>范围控制</small></span>
<span class="is-experimental"><b>微信 ClawBot</b><small>扫码连接</small></span>
</div>
</li>
<li class="release-item">
<div class="release-index">03</div>
<div class="release-copy">
<div class="release-label">多模态输入</div>
<h3>更多材料,一次带上</h3>
<p>单次最多 8 个附件,并已验证同时传入 5 张图片。限制保持可见,避免把超出边界的输入静默带入任务</p>
<div class="release-label">安全媒体</div>
<h3>远程消息中的图片和文件</h3>
<p>微信私聊支持图片与文件,单条消息最多 4 个附件。回复不会自动发送工作区中的已有文件</p>
</div>
<div class="release-visual attachment-visual" aria-hidden="true">
<div class="attachment-stack"><span></span><span></span><span></span></div>
<div><strong>8</strong><small>附件上限</small></div>
<div><strong>5</strong><small>图片上限</small></div>
<div><strong>4</strong><small>单条附件</small></div>
<div><strong>12MB</strong><small>合计上限</small></div>
</div>
</li>
<li class="release-item">
<div class="release-index">04</div>
<div class="release-copy">
<div class="release-label">图像生成</div>
<h3>清晰选择质量档位</h3>
<p>支持 auto、low、medium、high 四档质量。当前按单张结果呈现,不承诺批量多图生成</p>
<div class="release-label">Agent Runtime</div>
<h3>运行时与 Skills</h3>
<p>OpenCode 与 Continue 共用更一致的 Skills、系统消息和工具配置,并保留环境白名单、取消、超时和审批控制</p>
</div>
<div class="release-visual quality-visual" aria-label="图像质量档位">
<span>auto</span><span>low</span><span>medium</span><span class="is-selected">high</span>
<div class="release-visual quality-visual" aria-label="运行时能力">
<span>Skills</span><span>Tools</span><span>OpenCode</span><span class="is-selected">Continue</span>
</div>
</li>
</ol>
@@ -380,11 +383,11 @@
<div class="section-inner">
<div class="section-heading section-heading--center">
<div>
<p class="kicker">原生桌面体验</p>
<h2 id="download-title">准备好,在你的设备上运行</h2>
<p class="kicker">下载</p>
<h2 id="download-title">下载 GoodBuddy</h2>
</div>
<p>
v0.8.1 Release 提供经过校验的跨平台安装包与哈希清单,下载入口统一指向 GitHub Release
最新 Release 提供经过校验的跨平台安装包与哈希清单。进入官方下载页,按系统与架构选择安装包
</p>
</div>
@@ -396,7 +399,13 @@
</svg>
</div>
<div><h3>Windows</h3><p>x64 / arm64 · NSIS / 便携版</p></div>
<a class="button button--download is-disabled" aria-disabled="true" data-release-link>发布后开放</a>
<a
class="button button--download"
href="https://github.com/mesalogo/goodbuddy/releases/latest"
target="_blank"
rel="noreferrer"
data-release-link
>选择 Windows 安装包<span class="sr-only">(在新窗口打开)</span></a>
</article>
<article class="download-card">
<div class="platform-icon">
@@ -405,7 +414,13 @@
</svg>
</div>
<div><h3>macOS</h3><p>x64 / arm64 · DMG / ZIP</p></div>
<a class="button button--download is-disabled" aria-disabled="true" data-release-link>发布后开放</a>
<a
class="button button--download"
href="https://github.com/mesalogo/goodbuddy/releases/latest"
target="_blank"
rel="noreferrer"
data-release-link
>选择 macOS 安装包<span class="sr-only">(在新窗口打开)</span></a>
</article>
<article class="download-card">
<div class="platform-icon">
@@ -415,7 +430,13 @@
</svg>
</div>
<div><h3>Linux</h3><p>x64 / arm64 · AppImage / DEB</p></div>
<a class="button button--download is-disabled" aria-disabled="true" data-release-link>发布后开放</a>
<a
class="button button--download"
href="https://github.com/mesalogo/goodbuddy/releases/latest"
target="_blank"
rel="noreferrer"
data-release-link
>选择 Linux 安装包<span class="sr-only">(在新窗口打开)</span></a>
</article>
</div>
@@ -424,8 +445,8 @@
<circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" />
</svg>
<div>
<strong>Release 状态:尚未发布</strong>
<span>本站下载按钮由单一配置控制;正式发布前不会指向占位资产</span>
<strong>下载与校验</strong>
<span>下载入口始终指向最新正式 Release;安装前请按系统与架构选择文件,并核对 SHA-256 清单</span>
</div>
</div>
</div>
@@ -440,11 +461,10 @@
<path d="m17.5 24 4.5 4.5 9-10" />
</svg>
</div>
<p class="kicker">Security by boundary</p>
<h2 id="security-title">安全不是开关,<br />而是每一层的边界</h2>
<p class="kicker">安全设计</p>
<h2 id="security-title">主要安全边界</h2>
<p>
GoodBuddy 将桌面渲染、密钥、工具运行与用户数据分层处理
风险操作保持可见,未受信运行时不会绕过审批边界。
渲染界面不能直接读取密钥或调用 Node。工具和子运行时通过主进程受控访问系统能力
</p>
<a
class="text-link"
@@ -461,19 +481,19 @@
<div class="security-list">
<article>
<span class="security-number">01</span>
<div><h3>密钥不进入渲染层</h3><p>API 密钥留在主进程,并写入加密设置存储;网页界面不获得直接 Node 访问</p></div>
<div><h3>密钥仅存主进程</h3><p>API 密钥写入加密设置存储,不会暴露给渲染界面</p></div>
</article>
<article>
<span class="security-number">02</span>
<div><h3>跨进程能力明确暴露</h3><p>通过窄化的预加载桥接调用能力,IPC 输入经过共享模式校验,并核验可信发送方。</p></div>
<div><h3>IPC 输入经过校验</h3><p>预加载层只暴露明确的方法。IPC 输入使用共享模式校验,并检查发送方。</p></div>
</article>
<article>
<span class="security-number">03</span>
<div><h3>运行时按不可信处理</h3><p>OpenCode 与 Continue 子运行时受环境白名单、沙箱检查及逐工具审批约束</p></div>
<div><h3>运行时受限</h3><p>OpenCode 与 Continue 使用环境白名单、沙箱检查工具审批。</p></div>
</article>
<article>
<span class="security-number">04</span>
<div><h3>状态与审计语义可见</h3><p>取消、超时输出边界和活动记录属于执行链路的一部分,不用模糊的“已完成”掩盖风险</p></div>
<div><h3>工具执行可追踪</h3><p>工具名称、状态、取消、超时输出限制都会记录在活动中</p></div>
</article>
</div>
</div>
@@ -484,16 +504,18 @@
<div class="cta-card">
<div class="cta-orbit" aria-hidden="true"><span></span><span></span></div>
<div>
<p class="kicker">GoodBuddy 0.8.1</p>
<h2 id="cta-title">一个更能做事,也更懂边界的桌面伙伴。</h2>
<p>关注 Release,第一时间获取正式版本、校验信息与完整更新说明</p>
<p class="kicker">下载</p>
<h2 id="cta-title">选择适合你系统的安装包</h2>
<p>发布页提供安装文件、便携版和 SHA-256 校验清单</p>
</div>
<div class="cta-actions">
<a
class="button button--primary is-disabled"
aria-disabled="true"
class="button button--primary"
href="https://github.com/mesalogo/goodbuddy/releases/latest"
target="_blank"
rel="noreferrer"
data-release-link
>发布后开放</a>
>前往官方下载页<span class="sr-only">(在新窗口打开)</span></a>
<a
class="button button--secondary"
href="https://github.com/mesalogo/goodbuddy"
@@ -519,10 +541,10 @@
</svg>
<span>GoodBuddy</span>
</a>
<p>安全可控的桌面智能助手与 Agent 工作空间。</p>
<p>桌面 AI 助手与 Agent 工作空间。</p>
<div class="footer-links">
<a href="#features">功能</a>
<a href="#release">0.8.0</a>
<a href="#release">亮点</a>
<a href="#security">安全</a>
<a href="https://github.com/mesalogo/goodbuddy" target="_blank" rel="noreferrer">
GitHub<span class="sr-only">(在新窗口打开)</span>
@@ -532,7 +554,6 @@
</div>
</footer>
<script src="./site.config.js"></script>
<script src="./app.js"></script>
</body>
</html>
+23 -39
View File
@@ -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(/<svg\b[\s\S]*?<\/svg>/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(/<a\b(?=[^>]*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(", ")}`);
-5
View File
@@ -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",
});
+24
View File
@@ -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) => {
+25 -5
View File
@@ -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 () => {
+6 -7
View File
@@ -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 {
+48
View File
@@ -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,
+97 -2
View File
@@ -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',
+339 -6
View File
@@ -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<KnowledgeDatabase['listEvidence']>
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<string, FSWatcher>()
private readonly syncTimers = new Map<string, ReturnType<typeof setTimeout>>()
private readonly activeSyncs = new Map<string, Promise<void>>()
private readonly tasks = new Map<string, KnowledgeTaskSnapshot>()
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<ReturnType<UrlImporter['import']>>
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<void> {
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<boolean> {
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<void> {
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<void> {
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<GraphExtractionResult> {
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<string, string>()
for (const entity of result.entities) {
+22 -1
View File
@@ -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({
+23 -5
View File
@@ -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)
}
+5
View File
@@ -805,6 +805,11 @@ const desktopApi: DesktopApi = {
libraryId
)
},
reextractGraph: (libraryId) =>
ipcRenderer.invoke(
ipcChannels.knowledgeReextractGraph,
libraryId
) as Promise<void>,
selectFiles: async (libraryId, graphStrategy) => {
await ipcRenderer.invoke(ipcChannels.knowledgeSelectFiles, {
libraryId,
+153 -48
View File
@@ -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<HTMLButtonElement>('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(<App />)
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(<App />)
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(<App />)
@@ -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(<App />)
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(<App />)
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(<App />)
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(<App />)
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(<App />)
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(<App />)
fireEvent.change(screen.getByLabelText('专家角色'), {
target: { value: 'team' }
})
selectComposerOption('专家角色', '专家团队(并行)')
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '制定发布计划' }
})
@@ -3260,10 +3361,14 @@ describe('App', () => {
])
render(<App />)
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<HTMLButtonElement>('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()
})
+355 -179
View File
@@ -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<T extends string> = {
value: T
label: string
description: string
disabled?: boolean
}
function ComposerMenuSelect<T extends string>({
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<T>[]
value: T
}): React.JSX.Element {
const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(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<HTMLButtonElement>('[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 (
<div className={`runtime-picker composer-picker ${className}`}>
<button
aria-describedby={describedBy}
aria-expanded={menuOpen}
aria-haspopup="menu"
aria-label={`${ariaLabel}${selectedOption?.label ?? ''}`}
className="model-button composer-picker__button"
disabled={disabled}
onClick={() => onOpenChange(!menuOpen)}
onKeyDown={(event) => {
if (
!menuOpen &&
(event.key === 'ArrowDown' ||
event.key === 'Enter' ||
event.key === ' ')
) {
event.preventDefault()
onOpenChange(true)
}
}}
ref={buttonRef}
title={`${ariaLabel}${selectedOption?.label ?? ''}`}
type="button"
>
{icon}
<span className="model-button__label">
{selectedOption?.label}
</span>
<ChevronDown aria-hidden="true" size={14} />
</button>
{menuOpen && (
<div
aria-label={ariaLabel}
className="runtime-picker__menu composer-picker__menu"
onKeyDown={(event) => {
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>(
'[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) => (
<button
aria-checked={option.value === value}
disabled={option.disabled}
key={option.value}
onClick={() => {
onChange(option.value)
onOpenChange(false)
requestAnimationFrame(() => {
buttonRef.current?.focus()
})
}}
role="menuitemradio"
tabIndex={option.value === value ? 0 : -1}
type="button"
>
<span>{option.label}</span>
<small>{option.description}</small>
</button>
))}
</div>
)}
</div>
)
}
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<RuntimeSettings>()
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false)
const [composerMenuOpen, setComposerMenuOpen] = useState<
'expert' | 'mode' | undefined
>()
const runtimeMenuButtonRef = useRef<HTMLButtonElement>(null)
const runtimeMenuRef = useRef<HTMLDivElement>(null)
const [topbarMenuOpen, setTopbarMenuOpen] = useState(false)
const [runtimeSwitching, setRuntimeSwitching] = useState(false)
const [appearanceTheme, setAppearanceTheme] =
useState<AppearanceTheme>(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<string>[]
>(
() => [
{
value: '',
label: '通用助手',
description: '默认单助手'
},
{
value: 'team',
label: '专家团队(并行)',
description: '多个专家并行协作'
},
...assistantExperts.map((expert) => ({
value: expert.id,
label: expert.name,
description: expert.description || '自定义专家角色'
}))
],
[assistantExperts]
)
const workModeOptions = useMemo<
ComposerMenuOption<InteractiveWorkMode>[]
>(
() =>
interactiveWorkModes.map((value) => ({
value,
label: workModeLabels[value],
description:
value === 'execute'
? '通过审批后执行工具操作'
: '只读问答,不修改文件',
disabled:
value === 'execute' && !runtime?.supportsToolExecution
})),
[runtime?.supportsToolExecution]
)
const [appInfo, setAppInfo] = useState<AppInfo>()
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<string>()
const [knowledgeOperationCount, setKnowledgeOperationCount] = useState(0)
const knowledgeLoadRequestRef = useRef(0)
const failedKnowledgeLibraryIdRef = useRef<string | undefined>(
undefined
@@ -1216,8 +1458,6 @@ function App(): React.JSX.Element {
const scrollRef = useRef<HTMLDivElement>(null)
const sidebarRef = useRef<HTMLElement>(null)
const sidebarToggleRef = useRef<HTMLButtonElement>(null)
const topbarMenuRef = useRef<HTMLDivElement>(null)
const topbarMenuTriggerRef = useRef<HTMLButtonElement>(null)
const conversationActionTriggerRefs = useRef(
new Map<string, HTMLButtonElement>()
)
@@ -1279,66 +1519,6 @@ function App(): React.JSX.Element {
resizeComposerTextarea(inputRef.current)
}, [input])
useEffect(() => {
if (!topbarMenuOpen) {
return
}
const focusFrame = requestAnimationFrame(() => {
topbarMenuRef.current
?.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>(
'[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<void>
): Promise<void> => {
await action()
await refreshSelectedKnowledge()
const runKnowledgeSourceAction = async <T,>(
action: () => Promise<T>
): Promise<T> => {
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 {
<PanelRightOpen size={18} />
</button>
)}
<div className="topbar-menu" ref={topbarMenuRef}>
<button
aria-expanded={topbarMenuOpen}
aria-haspopup="menu"
aria-label="应用菜单"
className="icon-button"
onClick={() =>
setTopbarMenuOpen((current) => !current)
}
ref={topbarMenuTriggerRef}
type="button"
>
<MoreHorizontal size={18} />
</button>
{topbarMenuOpen && (
<div
aria-label="应用操作"
className="topbar-menu__popover"
role="menu"
>
<button
onClick={() => {
setTopbarMenuOpen(false)
setView('settings')
}}
role="menuitem"
type="button"
>
<ShieldCheck size={16} />
Runtime
</button>
<button
onClick={() => {
setTopbarMenuOpen(false)
notify({
tone: 'info',
message:
'输入问题后按 Enter 发送,Shift+Enter 换行。附件只会在你明确选择后发送。'
})
}}
role="menuitem"
type="button"
>
<CircleHelp size={16} />
使
</button>
</div>
<button
aria-label={
resolvedAppearanceTheme === 'dark'
? '切换浅色主题'
: '切换深色主题'
}
aria-pressed={resolvedAppearanceTheme === 'dark'}
className="icon-button theme-toggle-button"
onClick={toggleAppearanceTheme}
title={
resolvedAppearanceTheme === 'dark'
? '切换浅色主题'
: '切换深色主题'
}
type="button"
>
{resolvedAppearanceTheme === 'dark' ? (
<Sun aria-hidden="true" size={18} />
) : (
<Moon aria-hidden="true" size={18} />
)}
</div>
</button>
</div>
<WindowControls
onError={handleWindowControlError}
@@ -5260,11 +5441,11 @@ function App(): React.JSX.Element {
<div className="composer__input">
<textarea
aria-label="向 GoodBuddy 提问"
placeholder={
placeholder={`${
runtime?.capability === 'image-generation'
? '描述你想生成的图片…'
: '给 GoodBuddy 发消息…'
}
}\nEnter 发送 · Shift+Enter 换行 · 附件仅在选择后发送`}
ref={inputRef}
rows={3}
value={input}
@@ -5418,68 +5599,46 @@ function App(): React.JSX.Element {
className="composer__configuration"
role="group"
>
<label
className="composer__expert"
title="选择参与本次对话的专家角色"
>
<Bot aria-hidden="true" size={15} />
<select
aria-label="专家角色"
disabled={runtime?.capability === 'image-generation'}
onChange={(event) =>
setSelectedExpertId(event.target.value)
}
value={selectedExpertId}
>
<option value=""></option>
<option value="team"></option>
{assistantExperts.map((expert) => (
<option key={expert.id} value={expert.id}>
{expert.name}
</option>
))}
</select>
</label>
<label
className={`composer__mode composer__mode--${effectiveWorkMode}`}
title={`工作模式:${workModeLabels[effectiveWorkMode]}`}
>
{effectiveWorkMode === 'execute' ? (
<ShieldCheck aria-hidden="true" size={15} />
) : (
<CircleHelp aria-hidden="true" size={15} />
)}
<select
aria-describedby="work-mode-hint"
aria-label="工作模式"
onChange={(event) =>
setWorkMode(
event.target.value as InteractiveWorkMode
)
}
value={effectiveWorkMode}
>
{interactiveWorkModes.map((value) => (
<option
disabled={
value === 'execute' &&
!runtime?.supportsToolExecution
}
key={value}
value={value}
>
{workModeLabels[value]}
</option>
))}
</select>
</label>
<ComposerMenuSelect
ariaLabel="专家角色"
className="composer-picker--expert"
disabled={
runtime?.capability === 'image-generation'
}
icon={<Bot aria-hidden="true" size={15} />}
menuOpen={composerMenuOpen === 'expert'}
onChange={setSelectedExpertId}
onOpenChange={setExpertMenuOpen}
options={assistantExpertOptions}
value={selectedExpertId}
/>
<ComposerMenuSelect
ariaLabel="工作模式"
className={`composer-picker--mode composer-picker--${effectiveWorkMode}`}
describedBy="work-mode-hint"
icon={
effectiveWorkMode === 'execute' ? (
<ShieldCheck aria-hidden="true" size={15} />
) : (
<CircleHelp aria-hidden="true" size={15} />
)
}
menuOpen={composerMenuOpen === 'mode'}
onChange={setWorkMode}
onOpenChange={setModeMenuOpen}
options={workModeOptions}
value={effectiveWorkMode}
/>
<div className="runtime-picker">
<button
aria-expanded={runtimeMenuOpen}
aria-haspopup="menu"
className="model-button"
disabled={isRunning || runtimeSwitching}
onClick={() => setRuntimeMenuOpen(!runtimeMenuOpen)}
onClick={() => {
setComposerMenuOpen(undefined)
setRuntimeMenuOpen(!runtimeMenuOpen)
}}
onKeyDown={(event) => {
if (
!runtimeMenuOpen &&
@@ -5488,6 +5647,7 @@ function App(): React.JSX.Element {
event.key === ' ')
) {
event.preventDefault()
setComposerMenuOpen(undefined)
setRuntimeMenuOpen(true)
}
}}
@@ -5782,14 +5942,29 @@ function App(): React.JSX.Element {
)
}
onDeleteLibrary={deleteKnowledgeLibrary}
onUpdateLibrary={(libraryId, update) =>
runKnowledgeSourceAction(async () => {
onReextractGraph={async (libraryId) => {
await runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.reextractGraph(libraryId)
)
notify({
tone: 'success',
message: '知识图谱已重新抽取',
dedupeKey: `knowledge-graph:${libraryId}`
})
}}
onUpdateLibrary={async (libraryId, update) => {
await runKnowledgeSourceAction(async () => {
await window.goodbuddy.knowledge.updateLibrary(
libraryId,
update
)
})
}
notify({
tone: 'success',
message: '知识库设置已更新',
dedupeKey: `knowledge-library:${libraryId}`
})
}}
onDeleteRelation={(relationId) =>
runKnowledgeSourceAction(() =>
window.goodbuddy.knowledge.deleteRelation(relationId)
@@ -5892,6 +6067,7 @@ function App(): React.JSX.Element {
}
selectedLibraryId={knowledgeSnapshot.selectedLibraryId}
sources={knowledgeSnapshot.sources}
tasks={knowledgeSnapshot.tasks}
/>
</PageShell>
) : view === 'heartbeat' ? (
+132 -43
View File
@@ -8,7 +8,7 @@ import {
type EChartsCoreOption
} from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type {
KnowledgeGraphNode,
KnowledgeGraphRelation
@@ -59,6 +59,42 @@ function readToken(name: string): string {
.trim()
}
function graphTypeStyles(nodes: readonly ChartKnowledgeGraphNode[]): Map<
string,
{ color: string; borderColor: string }
> {
const palette = Array.from({ length: 8 }, (_, index) => ({
color: readToken(`--graph-node-${index + 1}`),
borderColor: readToken(`--graph-node-${index + 1}-border`)
}))
return new Map(
[...new Set(nodes.map((node) => node.type))]
.sort((left, right) => left.localeCompare(right, 'zh-CN'))
.map((type, index) => [type, palette[index % palette.length]!])
)
}
function graphRevision(
nodes: readonly ChartKnowledgeGraphNode[],
relations: readonly ChartKnowledgeGraphRelation[]
): string {
return JSON.stringify({
nodes: nodes.map((node) => [
node.id,
node.label,
node.type,
node.x,
node.y
]),
relations: relations.map((relation) => [
relation.id,
relation.sourceId,
relation.targetId,
relation.type
])
})
}
function createOption({
nodes,
relations,
@@ -72,17 +108,50 @@ function createOption({
const textSecondary = readToken('--text-secondary')
const textMuted = readToken('--text-muted')
const accent = readToken('--accent')
const accentSelected = readToken('--accent-selected')
const accentSubtle = readToken('--accent-subtle')
const surfaceRaised = readToken('--surface-raised')
const borderDefault = readToken('--border-default')
const typeStyles = graphTypeStyles(nodes)
const dense = nodes.length > 24
const veryDense = nodes.length > 60
const degreeByNodeId = new Map(nodes.map((node) => [node.id, 0]))
for (const relation of relations) {
degreeByNodeId.set(
relation.sourceId,
(degreeByNodeId.get(relation.sourceId) ?? 0) + 1
)
degreeByNodeId.set(
relation.targetId,
(degreeByNodeId.get(relation.targetId) ?? 0) + 1
)
}
const maximumDegree = Math.max(1, ...degreeByNodeId.values())
const keyNodeCount = Math.min(
nodes.length,
Math.max(8, Math.min(16, Math.round(Math.sqrt(nodes.length) * 1.4)))
)
const keyNodeIds = new Set(
[...nodes]
.sort((left, right) => {
const degreeDifference =
(degreeByNodeId.get(right.id) ?? 0) -
(degreeByNodeId.get(left.id) ?? 0)
return (
degreeDifference ||
left.label.localeCompare(right.label, 'zh-CN')
)
})
.slice(0, keyNodeCount)
.map((node) => node.id)
)
const reducedMotion =
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
const showEdgeLabels =
nodes.length <= 18 && relations.length <= 24
return {
animation: !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches,
animation: !reducedMotion,
animationDuration: 220,
animationDurationUpdate: 160,
tooltip: {
trigger: 'item',
renderMode: 'richText',
@@ -113,49 +182,54 @@ function createOption({
},
force: {
repulsion: dense
? Math.min(520, 130 + nodes.length * 3)
: 220,
gravity: dense ? 0.14 : 0.08,
edgeLength: dense
? veryDense
? [45, 80]
: [60, 110]
: [110, 190],
friction: dense ? 0.5 : 0.6,
layoutAnimation:
!window.matchMedia?.('(prefers-reduced-motion: reduce)')
.matches
? Math.min(480, 220 + nodes.length * 2)
: 200,
gravity: 0.06,
edgeLength: dense ? [70, 130] : [90, 150],
friction: 0.08,
layoutAnimation: !reducedMotion
},
selectedMode: 'single',
symbol: 'circle',
categories: [...typeStyles.entries()].map(([name, style]) => ({
name,
itemStyle: style
})),
data: nodes.map((node) => {
const selected = node.id === selectedNodeId
const typeStyle = typeStyles.get(node.type) ?? {
color: accentSubtle,
borderColor: accent
}
const degree = degreeByNodeId.get(node.id) ?? 0
const degreeRatio = Math.sqrt(degree / maximumDegree)
const symbolSize = dense
? 16 + degreeRatio * 16
: 32 + degreeRatio * 16
const showLabel = !dense || keyNodeIds.has(node.id)
return {
id: node.id,
name: node.label,
type: node.type,
...(dense ? {} : { x: node.x, y: node.y }),
value: degree,
category: node.type,
x: node.x,
y: node.y,
draggable: true,
selected,
symbolSize: selected
? dense
? 34
: 60
: dense
? veryDense
? 18
: 24
: 52,
symbolSize: selected ? symbolSize + 4 : symbolSize,
itemStyle: {
color: selected ? accentSelected : accentSubtle,
borderColor: accent,
borderWidth: selected ? 3 : 2
color: typeStyle.color,
borderColor: selected ? accent : typeStyle.borderColor,
borderWidth: selected ? 2.5 : 1.5
},
label: {
show: !dense || selected,
show: showLabel || selected,
color: textPrimary,
fontSize: dense ? 11 : 12,
fontWeight: 700,
fontWeight: keyNodeIds.has(node.id) ? 650 : 500,
position: dense ? 'right' : 'inside',
distance: dense ? 5 : 0,
formatter:
node.label.length > 8
? `${node.label.slice(0, 8)}`
@@ -163,15 +237,19 @@ function createOption({
},
emphasis: {
focus: 'adjacency',
itemStyle: {
borderColor: accent,
borderWidth: 2.5
},
label: {
show: true
}
},
select: {
itemStyle: {
color: accentSelected,
color: typeStyle.color,
borderColor: accent,
borderWidth: 3
borderWidth: 2.5
},
label: {
show: true
@@ -186,14 +264,14 @@ function createOption({
value: relation.type,
description: relation.description,
lineStyle: {
color: textMuted,
width: 1.5,
curveness: 0.08
color: borderDefault,
width: 1.2,
opacity: 0.72,
curveness: 0.06
}
})),
edgeSymbol: ['none', 'arrow'],
edgeSymbolSize: 8,
autoCurveness: true,
edgeSymbolSize: 6,
edgeLabel: {
show: showEdgeLabels,
color: textSecondary,
@@ -229,12 +307,23 @@ export function KnowledgeGraphChart({
const onMoveNodeRef = useRef(onMoveNode)
const onSelectNodeRef = useRef(onSelectNode)
const onZoomChangeRef = useRef(onZoomChange)
const nodesRef = useRef(nodes)
const relationsRef = useRef(relations)
const dragRef = useRef<NodeDrag | undefined>(undefined)
const viewportRef = useRef<GraphViewport>({})
const zoomRef = useRef(zoom)
const appliedZoomRef = useRef<number | undefined>(undefined)
const dataRevision = useMemo(
() => graphRevision(nodes, relations),
[nodes, relations]
)
const [themeRevision, setThemeRevision] = useState(0)
useEffect(() => {
nodesRef.current = nodes
relationsRef.current = relations
}, [nodes, relations])
useEffect(() => {
onMoveNodeRef.current = onMoveNode
onSelectNodeRef.current = onSelectNode
@@ -417,8 +506,8 @@ export function KnowledgeGraphChart({
return
}
const option = createOption({
nodes,
relations,
nodes: nodesRef.current,
relations: relationsRef.current,
selectedNodeId: undefined,
zoom: zoomRef.current
})
@@ -437,7 +526,7 @@ export function KnowledgeGraphChart({
{ notMerge: true }
)
appliedZoomRef.current = zoomRef.current
}, [nodes, relations, themeRevision])
}, [dataRevision, themeRevision])
useEffect(() => {
const chart = chartRef.current
@@ -466,7 +555,7 @@ export function KnowledgeGraphChart({
seriesIndex: 0
})
const dataIndex = selectedNodeId
? nodes.findIndex((node) => node.id === selectedNodeId)
? nodesRef.current.findIndex((node) => node.id === selectedNodeId)
: -1
if (dataIndex >= 0) {
chart.dispatchAction({
@@ -475,7 +564,7 @@ export function KnowledgeGraphChart({
dataIndex
})
}
}, [nodes, selectedNodeId, themeRevision])
}, [dataRevision, selectedNodeId, themeRevision])
return (
<div
+141 -7
View File
@@ -129,6 +129,7 @@ function createProps(
onCreateLibrary: vi.fn(),
onDeleteLibrary: vi.fn(),
onUpdateLibrary: vi.fn(),
onReextractGraph: vi.fn(),
onImportFiles: vi.fn(),
onImportDirectory: vi.fn(),
onImportUrl: vi.fn(),
@@ -223,6 +224,91 @@ describe('KnowledgeWorkspace', () => {
expect(screen.getByText('架构说明.md')).toBeInTheDocument()
})
it('uses shared tabs and keeps graph configuration in settings', () => {
const onUpdateLibrary = vi.fn()
render(<KnowledgeWorkspace {...createProps({ onUpdateLibrary })} />)
const tabs = screen.getByRole('tablist', { name: '知识库视图' })
expect(within(tabs).getAllByRole('tab').map((item) => item.textContent))
.toEqual(['文档与来源', '知识图谱', '任务中心', '设置'])
expect(
screen.queryByRole('checkbox', { name: '知识图谱' })
).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '设置' }))
fireEvent.click(screen.getByRole('checkbox', { name: //u }))
expect(onUpdateLibrary).toHaveBeenCalledWith('library-1', {
graphEnabled: false
})
})
it('shows parsing, embedding, and graph progress in the task center', () => {
render(
<KnowledgeWorkspace
{...createProps({
tasks: [
{
id: 'task-1',
libraryId: 'library-1',
documentId: 'document-1',
documentName: '架构说明.md',
kind: 'graph',
status: 'running',
progress: 40,
message: '正在重新抽取知识图谱',
createdAt: '2026-08-10T08:00:00.000Z',
startedAt: '2026-08-10T08:00:01.000Z'
}
]
})}
/>
)
fireEvent.click(
screen.getByRole('tab', { name: /^/u })
)
expect(screen.getByText('图谱抽取')).toBeInTheDocument()
expect(screen.getByText('正在重新抽取知识图谱')).toBeInTheDocument()
expect(
screen.getByRole('progressbar', {
name: '架构说明.md 图谱抽取进度'
})
).toHaveValue(40)
})
it('edits library metadata from the detail header', async () => {
const onUpdateLibrary = vi.fn()
render(<KnowledgeWorkspace {...createProps({ onUpdateLibrary })} />)
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
fireEvent.change(screen.getByLabelText('名称'), {
target: { value: '研发知识' }
})
fireEvent.change(screen.getByLabelText('描述'), {
target: { value: '研发资料与设计说明' }
})
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() =>
expect(onUpdateLibrary).toHaveBeenCalledWith('library-1', {
name: '研发知识',
description: '研发资料与设计说明'
})
)
})
it('reextracts the graph from the graph tab', async () => {
const onReextractGraph = vi.fn()
render(<KnowledgeWorkspace {...createProps({ onReextractGraph })} />)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
fireEvent.click(screen.getByRole('button', { name: '重新抽取' }))
await waitFor(() =>
expect(onReextractGraph).toHaveBeenCalledWith('library-1')
)
})
it('renders and filters graph nodes with their relationships', async () => {
render(<KnowledgeWorkspace {...createProps()} />)
@@ -335,7 +421,7 @@ describe('KnowledgeWorkspace', () => {
it('manages the graph chart, zoom, selection, movement, and cleanup', () => {
const onMoveNode = vi.fn()
const { unmount } = render(
const { rerender, unmount } = render(
<KnowledgeWorkspace {...createProps({ onMoveNode })} />
)
@@ -351,15 +437,27 @@ describe('KnowledgeWorkspace', () => {
expect.objectContaining({
series: [
expect.objectContaining({
categories: expect.arrayContaining([
expect.objectContaining({ name: '产品' }),
expect.objectContaining({ name: '技术' })
]),
layout: 'force',
symbol: 'circle',
type: 'graph',
data: expect.arrayContaining([
expect.objectContaining({
category: '产品',
id: 'entity-1',
name: 'GoodBuddy'
})
]),
force: expect.objectContaining({
edgeLength: [90, 150],
friction: 0.08,
gravity: 0.06,
layoutAnimation: true,
repulsion: 200
}),
links: [
expect.objectContaining({
id: 'relation-1',
@@ -371,6 +469,12 @@ describe('KnowledgeWorkspace', () => {
}),
{ notMerge: true }
)
const stableOptionCallCount =
echartsMock.chart.setOption.mock.calls.length
rerender(<KnowledgeWorkspace {...createProps({ onMoveNode })} />)
expect(echartsMock.chart.setOption).toHaveBeenCalledTimes(
stableOptionCallCount
)
fireEvent.click(screen.getByRole('button', { name: '放大图谱' }))
expect(screen.getByText('115%')).toBeInTheDocument()
@@ -491,7 +595,7 @@ describe('KnowledgeWorkspace', () => {
delete document.documentElement.dataset.theme
})
it('reduces labels and node size for dense graphs', () => {
it('sizes dense nodes by degree and labels key entities', () => {
const graphNodes = Array.from({ length: 30 }, (_, index) => ({
id: `entity-${index}`,
label: `实体 ${index}`,
@@ -501,7 +605,23 @@ describe('KnowledgeWorkspace', () => {
}))
render(
<KnowledgeWorkspace
{...createProps({ graphNodes, graphRelations: [] })}
{...createProps({
graphNodes,
graphRelations: [
{
id: 'relation-dense-1',
sourceId: 'entity-0',
targetId: 'entity-1',
type: '关联'
},
{
id: 'relation-dense-2',
sourceId: 'entity-0',
targetId: 'entity-2',
type: '关联'
}
]
})}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
@@ -513,13 +633,27 @@ describe('KnowledgeWorkspace', () => {
data: expect.arrayContaining([
expect.objectContaining({
id: 'entity-0',
symbolSize: 24,
symbolSize: 32,
x: 0,
y: 0,
label: expect.objectContaining({
position: 'right',
show: true
})
}),
expect.objectContaining({
id: 'entity-29',
symbolSize: 16,
label: expect.objectContaining({ show: false })
})
]),
edgeLabel: expect.objectContaining({ show: false }),
force: expect.objectContaining({
repulsion: 220
edgeLength: [70, 130],
friction: 0.08,
gravity: 0.06,
layoutAnimation: true,
repulsion: 280
})
})
]
@@ -529,8 +663,8 @@ describe('KnowledgeWorkspace', () => {
const option = echartsMock.chart.setOption.mock.calls.at(-1)?.[0] as {
series?: Array<{ data?: Array<Record<string, unknown>> }>
}
expect(option.series?.[0]?.data?.[0]).not.toHaveProperty('x')
expect(option.series?.[0]?.data?.[0]).not.toHaveProperty('y')
expect(option.series?.[0]?.data?.[0]).toHaveProperty('x', 0)
expect(option.series?.[0]?.data?.[0]).toHaveProperty('y', 0)
})
it('creates relationships, merges entities, and opens graph evidence', async () => {
+456 -59
View File
@@ -12,6 +12,7 @@ import {
FolderOpen,
GitMerge,
Link2,
ListChecks,
LoaderCircle,
Network,
Pencil,
@@ -19,6 +20,7 @@ import {
RefreshCw,
RotateCcw,
Search,
Settings2,
Trash2,
UploadCloud,
X,
@@ -136,6 +138,21 @@ export type KnowledgeEvidence = {
location?: string
}
export type KnowledgeTaskItem = {
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 KnowledgeEntityUpdate = {
label: string
type: string
@@ -158,6 +175,7 @@ export type KnowledgeWorkspaceProps = {
graphNodes: readonly KnowledgeGraphNode[]
graphRelations: readonly KnowledgeGraphRelation[]
evidence: readonly KnowledgeEvidence[]
tasks?: readonly KnowledgeTaskItem[]
loading?: boolean
loadError?: string
onRetryLoad: () => void | Promise<void>
@@ -169,10 +187,13 @@ export type KnowledgeWorkspaceProps = {
onUpdateLibrary: (
libraryId: string,
update: {
graphEnabled: boolean
graphStrategy: KnowledgeGraphStrategy
name?: string
description?: string
graphEnabled?: boolean
graphStrategy?: KnowledgeGraphStrategy
}
) => void | Promise<void>
onReextractGraph: (libraryId: string) => void | Promise<void>
onImportFiles: (
libraryId: string,
files: File[],
@@ -219,7 +240,7 @@ export type KnowledgeWorkspaceProps = {
onOpenEvidence?: (evidence: KnowledgeEvidence) => void
}
type WorkspaceTab = 'documents' | 'graph'
type WorkspaceTab = 'documents' | 'graph' | 'tasks' | 'settings'
const storageModeLabels: Record<KnowledgeStorageMode, string> = {
reference: '引用原文件',
@@ -590,6 +611,147 @@ function CreateLibraryWizard({
)
}
function EditLibraryDialog({
library,
onCancel,
onConfirm
}: {
library: KnowledgeLibrary
onCancel: () => void
onConfirm: (update: {
name: string
description: string
}) => void | Promise<void>
}): React.JSX.Element {
const [name, setName] = useState(library.name)
const [description, setDescription] = useState(library.description ?? '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string>()
const dialogRef = useRef<HTMLDivElement>(null)
const nameRef = useRef<HTMLInputElement>(null)
useEffect(() => {
nameRef.current?.focus()
}, [])
const submit = async (
event: React.FormEvent<HTMLFormElement>
): Promise<void> => {
event.preventDefault()
const normalizedName = name.trim()
if (!normalizedName) {
setError('请输入知识库名称')
return
}
setSaving(true)
setError(undefined)
try {
await onConfirm({
name: normalizedName,
description: description.trim()
})
onCancel()
} catch (reason) {
setError(toErrorMessage(reason))
} finally {
setSaving(false)
}
}
return (
<div
aria-label="编辑知识库"
aria-modal="true"
onKeyDown={(event) => {
if (event.key === 'Escape' && !saving) {
event.preventDefault()
onCancel()
return
}
trapTabFocus(event, dialogRef.current)
}}
ref={dialogRef}
role="dialog"
style={{
position: 'fixed',
inset: 0,
zIndex: 50,
display: 'grid',
placeItems: 'center',
padding: 20,
background: 'var(--overlay-backdrop)'
}}
>
<form
aria-label="编辑知识库表单"
onSubmit={(event) => void submit(event)}
style={{
...styles.surface,
display: 'grid',
width: 'min(480px, 100%)',
padding: 20,
boxShadow: 'var(--shadow-dialog)',
gap: 14
}}
>
<div>
<h2 style={{ margin: 0 }}></h2>
<p style={{ ...styles.muted, margin: '6px 0 0' }}>
</p>
</div>
<label style={styles.label}>
<input
onChange={(event) => setName(event.currentTarget.value)}
ref={nameRef}
style={styles.input}
value={name}
/>
</label>
<label style={styles.label}>
<textarea
onChange={(event) => setDescription(event.currentTarget.value)}
rows={4}
style={{ ...styles.input, resize: 'vertical' }}
value={description}
/>
</label>
{error && (
<p role="alert" style={{ color: 'var(--danger)', margin: 0 }}>
{error}
</p>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button
className="secondary-button"
disabled={saving}
onClick={onCancel}
style={styles.button}
type="button"
>
</button>
<button
className="primary-button"
disabled={saving}
style={styles.button}
type="submit"
>
{saving ? (
<LoaderCircle aria-hidden="true" size={15} />
) : (
<Check aria-hidden="true" size={15} />
)}
{saving ? '保存中…' : '保存修改'}
</button>
</div>
</form>
</div>
)
}
function DeleteLibraryDialog({
library,
onCancel,
@@ -1488,10 +1650,192 @@ function RelationForm({
)
}
function KnowledgeSettingsView({
library,
onUpdateLibrary
}: {
library: KnowledgeLibrary
onUpdateLibrary: KnowledgeWorkspaceProps['onUpdateLibrary']
}): React.JSX.Element {
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string>()
const update = async (
change: Parameters<KnowledgeWorkspaceProps['onUpdateLibrary']>[1]
): Promise<void> => {
setSaving(true)
setError(undefined)
try {
await onUpdateLibrary(library.id, change)
} catch (reason) {
setError(toErrorMessage(reason))
} finally {
setSaving(false)
}
}
return (
<div className="knowledge-settings">
<section
aria-labelledby="knowledge-graph-settings-title"
style={{ ...styles.surface, padding: 16 }}
>
<div>
<h3 id="knowledge-graph-settings-title" style={{ margin: 0 }}>
</h3>
<p style={{ ...styles.muted, margin: '6px 0 0' }}>
</p>
</div>
<label
className="knowledge-settings__toggle"
style={{ ...styles.surface, cursor: saving ? 'wait' : 'pointer' }}
>
<input
checked={library.graphEnabled}
disabled={saving}
onChange={(event) =>
void update({ graphEnabled: event.currentTarget.checked })
}
type="checkbox"
/>
<span>
<strong style={{ display: 'block' }}></strong>
<span style={styles.muted}>
</span>
</span>
</label>
<label style={styles.label}>
<select
aria-label="知识图谱抽取策略"
disabled={!library.graphEnabled || saving}
onChange={(event) =>
void update({
graphStrategy:
event.currentTarget.value as KnowledgeGraphStrategy
})
}
style={styles.input}
value={library.graphStrategy}
>
{Object.entries(strategyLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<span style={styles.muted}>
</span>
</label>
{error && (
<p role="alert" style={{ color: 'var(--danger)', margin: 0 }}>
{error}
</p>
)}
</section>
</div>
)
}
const taskKindLabels: Record<KnowledgeTaskItem['kind'], string> = {
parsing: '文档解析',
embedding: '向量化',
graph: '图谱抽取'
}
const taskStatusLabels: Record<KnowledgeTaskItem['status'], string> = {
queued: '等待中',
running: '进行中',
succeeded: '已完成',
failed: '失败',
skipped: '已跳过'
}
function KnowledgeTasksView({
tasks
}: {
tasks: readonly KnowledgeTaskItem[]
}): React.JSX.Element {
const activeCount = tasks.filter(
(task) => task.status === 'queued' || task.status === 'running'
).length
const failedCount = tasks.filter(
(task) => task.status === 'failed'
).length
if (tasks.length === 0) {
return (
<EmptyState
description="导入或同步文档后,可以在这里查看解析、向量化和图谱抽取进度。"
icon={<ListChecks size={30} />}
level="section"
title="还没有知识任务"
/>
)
}
return (
<section aria-labelledby="knowledge-tasks-title">
<div className="knowledge-tasks__summary">
<div>
<h3 id="knowledge-tasks-title" style={{ margin: 0 }}>
</h3>
<p style={{ ...styles.muted, margin: '5px 0 0' }}>
{tasks.length}
</p>
</div>
<div className="knowledge-tasks__metrics">
<span> {activeCount}</span>
<span> {failedCount}</span>
</div>
</div>
<ol className="knowledge-task-list">
{tasks.map((task) => (
<li className="knowledge-task" key={task.id}>
<div className="knowledge-task__heading">
<div>
<strong>{task.documentName}</strong>
<span>{taskKindLabels[task.kind]}</span>
</div>
<span
className={`knowledge-task__status knowledge-task__status--${task.status}`}
>
{taskStatusLabels[task.status]}
</span>
</div>
<div className="knowledge-task__progress">
<progress
aria-label={`${task.documentName} ${taskKindLabels[task.kind]}进度`}
max={100}
value={task.progress}
/>
<span>{task.progress}%</span>
</div>
<div className="knowledge-task__meta">
<span>{task.message || '等待处理'}</span>
<time dateTime={task.completedAt ?? task.startedAt ?? task.createdAt}>
{new Date(
task.completedAt ?? task.startedAt ?? task.createdAt
).toLocaleString('zh-CN')}
</time>
</div>
</li>
))}
</ol>
</section>
)
}
function GraphView({
evidence,
graphNodes,
graphRelations,
libraryId,
onCreateEntity,
onCreateRelation,
onDeleteEntity,
@@ -1499,6 +1843,7 @@ function GraphView({
onMergeEntities,
onMoveNode,
onOpenEvidence,
onReextractGraph,
onUpdateEntity,
onUpdateRelation
}: Pick<
@@ -1513,9 +1858,12 @@ function GraphView({
| 'onMergeEntities'
| 'onMoveNode'
| 'onOpenEvidence'
| 'onReextractGraph'
| 'onUpdateEntity'
| 'onUpdateRelation'
>): React.JSX.Element {
> & {
libraryId: string
}): React.JSX.Element {
const [query, setQuery] = useState('')
const [typeFilter, setTypeFilter] = useState('all')
const [selectedNodeId, setSelectedNodeId] = useState<string>()
@@ -1526,6 +1874,8 @@ function GraphView({
const [mergeTargetId, setMergeTargetId] = useState('')
const [zoom, setZoom] = useState(1)
const [relationsExpanded, setRelationsExpanded] = useState(false)
const [reextracting, setReextracting] = useState(false)
const [reextractError, setReextractError] = useState<string>()
const nodeMap = useMemo(
() => new Map(graphNodes.map((node) => [node.id, node])),
@@ -1658,6 +2008,22 @@ function GraphView({
</option>
))}
</select>
<button
className="secondary-button"
disabled={reextracting}
onClick={() => {
setReextracting(true)
setReextractError(undefined)
void Promise.resolve(onReextractGraph(libraryId))
.catch((reason) => setReextractError(toErrorMessage(reason)))
.finally(() => setReextracting(false))
}}
style={styles.button}
type="button"
>
<RefreshCw aria-hidden="true" size={15} />
{reextracting ? '重新抽取中…' : '重新抽取'}
</button>
<button
className="secondary-button"
onClick={() => {
@@ -1704,6 +2070,14 @@ function GraphView({
>
<ZoomIn aria-hidden="true" size={16} />
</button>
{reextractError && (
<span
className="knowledge-graph__toolbar-error"
role="alert"
>
{reextractError}
</span>
)}
</div>
{graphNodes.length === 0 ? (
<div
@@ -2117,6 +2491,7 @@ export function KnowledgeWorkspace({
graphNodes,
graphRelations,
evidence,
tasks = [],
loading = false,
loadError,
onRetryLoad,
@@ -2124,6 +2499,7 @@ export function KnowledgeWorkspace({
onCreateLibrary,
onDeleteLibrary,
onUpdateLibrary,
onReextractGraph,
onImportFiles,
onImportDirectory,
onImportUrl,
@@ -2144,8 +2520,11 @@ export function KnowledgeWorkspace({
const [creating, setCreating] = useState(false)
const [mobileListOpen, setMobileListOpen] = useState(false)
const [tab, setTab] = useState<WorkspaceTab>('documents')
const [editingLibrary, setEditingLibrary] =
useState<KnowledgeLibrary>()
const [deletingLibrary, setDeletingLibrary] =
useState<KnowledgeLibrary>()
const editLibraryTriggerRef = useRef<HTMLButtonElement>(null)
const deleteLibraryTriggerRef = useRef<HTMLButtonElement>(null)
const selectedLibrary =
libraries.find((library) => library.id === selectedLibraryId) ??
@@ -2158,6 +2537,9 @@ export function KnowledgeWorkspace({
(document) => document.libraryId === selectedLibrary.id
)
: []
const libraryTasks = selectedLibrary
? tasks.filter((task) => task.libraryId === selectedLibrary.id)
: []
useEffect(() => {
if (
@@ -2167,24 +2549,35 @@ export function KnowledgeWorkspace({
onSelectLibrary(selectedLibrary.id)
}
}, [onSelectLibrary, selectedLibrary, selectedLibraryId])
const visibleTab =
selectedLibrary?.graphEnabled === false ? 'documents' : tab
const visibleTab = tab
const workspaceTabs: ReadonlyArray<PageTab<WorkspaceTab>> = [
{
id: 'documents',
label: '文档与来源',
icon: <FileText aria-hidden="true" size={15} />
},
...(selectedLibrary?.graphEnabled
? [
{
id: 'graph' as const,
label: '知识图谱',
icon: <Network aria-hidden="true" size={15} />
}
]
: [])
{
id: 'graph',
label: '知识图谱',
icon: <Network aria-hidden="true" size={15} />
},
{
id: 'tasks',
label: '任务中心',
icon: <ListChecks aria-hidden="true" size={15} />
},
{
id: 'settings',
label: '设置',
icon: <Settings2 aria-hidden="true" size={15} />
}
]
const closeEditDialog = (): void => {
setEditingLibrary(undefined)
requestAnimationFrame(() =>
editLibraryTriggerRef.current?.focus()
)
}
const closeDeleteDialog = (): void => {
setDeletingLibrary(undefined)
requestAnimationFrame(() =>
@@ -2445,49 +2838,16 @@ export function KnowledgeWorkspace({
<div
className="knowledge-workspace__header-actions"
>
<label
style={{
display: 'flex',
alignItems: 'center',
color: 'var(--text-secondary)',
fontSize: 12,
gap: 6
}}
<button
className="secondary-button"
onClick={() => setEditingLibrary(selectedLibrary)}
ref={editLibraryTriggerRef}
style={styles.button}
type="button"
>
<input
checked={selectedLibrary.graphEnabled}
onChange={(event) =>
void onUpdateLibrary(selectedLibrary.id, {
graphEnabled: event.currentTarget.checked,
graphStrategy: selectedLibrary.graphStrategy
})
}
type="checkbox"
/>
</label>
{selectedLibrary.graphEnabled && (
<select
aria-label="知识图谱抽取策略"
className="knowledge-workspace__strategy"
onChange={(event) =>
void onUpdateLibrary(selectedLibrary.id, {
graphEnabled: true,
graphStrategy:
event.currentTarget
.value as KnowledgeGraphStrategy
})
}
style={styles.input}
value={selectedLibrary.graphStrategy}
>
{Object.entries(strategyLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
)}
<Pencil aria-hidden="true" size={15} />
</button>
<button
aria-label={`删除知识库 ${selectedLibrary.name}`}
className="danger-button danger-button--quiet"
@@ -2497,7 +2857,7 @@ export function KnowledgeWorkspace({
type="button"
>
<Trash2 aria-hidden="true" size={15} />
</button>
</div>
</header>
@@ -2529,11 +2889,13 @@ export function KnowledgeWorkspace({
onSyncSource={onSyncSource}
sources={librarySources}
/>
) : (
) : visibleTab === 'graph' &&
selectedLibrary.graphEnabled ? (
<GraphView
evidence={evidence}
graphNodes={graphNodes}
graphRelations={graphRelations}
libraryId={selectedLibrary.id}
onCreateEntity={onCreateEntity}
onCreateRelation={onCreateRelation}
onDeleteEntity={onDeleteEntity}
@@ -2541,14 +2903,49 @@ export function KnowledgeWorkspace({
onMergeEntities={onMergeEntities}
onMoveNode={onMoveNode}
onOpenEvidence={onOpenEvidence}
onReextractGraph={onReextractGraph}
onUpdateEntity={onUpdateEntity}
onUpdateRelation={onUpdateRelation}
/>
) : visibleTab === 'graph' ? (
<EmptyState
action={
<button
className="secondary-button"
onClick={() => setTab('settings')}
style={styles.button}
type="button"
>
<Settings2 aria-hidden="true" size={15} />
</button>
}
description="在“设置”中启用知识图谱后,可以查看实体关系并重新抽取。"
icon={<Network size={30} />}
level="section"
title="知识图谱未启用"
/>
) : visibleTab === 'tasks' ? (
<KnowledgeTasksView tasks={libraryTasks} />
) : (
<KnowledgeSettingsView
library={selectedLibrary}
onUpdateLibrary={onUpdateLibrary}
/>
)}
</div>
</>
)}
</section>
{editingLibrary && (
<EditLibraryDialog
library={editingLibrary}
onCancel={closeEditDialog}
onConfirm={(update) =>
onUpdateLibrary(editingLibrary.id, update)
}
/>
)}
{deletingLibrary && (
<DeleteLibraryDialog
library={deletingLibrary}
+24
View File
@@ -497,6 +497,30 @@ describe('SettingsPanel runtime files', () => {
expect(content).toHaveClass('settings-panel__content')
})
it('omits the redundant close-only footer on passive settings pages', () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
presentation="page"
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
expect(
screen.getByRole('button', { name: '关闭设置' })
).toBeInTheDocument()
expect(
screen
.getByRole('region', { name: '设置中心' })
.querySelector('.settings-panel__footer')
).toBeNull()
})
it('uses one first-level heading for the settings page', () => {
render(
<SettingsPanel
+49 -38
View File
@@ -346,6 +346,11 @@ export function SettingsPanel({
activeTab === 'runtime' ||
activeTab === 'security' ||
activeTab === 'roles'
const showFooter =
presentation !== 'page' ||
configurationTab ||
Boolean(error) ||
saved
const handleTabKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>,
@@ -2297,49 +2302,55 @@ export function SettingsPanel({
</div>
</div>
<footer className="settings-panel__footer">
<div className="settings-feedback">
{error && <span className="settings-error">{error}</span>}
{saved && (
<span className="settings-success">
<Check size={14} />
{connectionResult ?? '设置已保存'}
</span>
)}
</div>
<button className="secondary-button" onClick={close} type="button">
{configurationTab ? '取消' : '关闭'}
</button>
{configurationTab && (
<>
{(activeTab === 'runtime' ||
(activeTab === 'model' && modelType === 'llm')) && (
{showFooter && (
<footer className="settings-panel__footer">
<div className="settings-feedback">
{error && <span className="settings-error">{error}</span>}
{saved && (
<span className="settings-success">
<Check size={14} />
{connectionResult ?? '设置已保存'}
</span>
)}
</div>
<button
className="secondary-button"
onClick={close}
type="button"
>
{configurationTab ? '取消' : '关闭'}
</button>
{configurationTab && (
<>
{(activeTab === 'runtime' ||
(activeTab === 'model' && modelType === 'llm')) && (
<button
className="secondary-button"
disabled={saving || testing}
onClick={() => void testConnection()}
type="button"
>
{testing
? '测试中…'
: activeTab === 'model'
? '保存并测试模型'
: agentRuntimeType === 'opencode'
? '保存并测试 OpenCode'
: '保存并测试 Continue'}
</button>
)}
<button
className="secondary-button"
className="primary-button"
disabled={saving || testing}
onClick={() => void testConnection()}
onClick={() => void save()}
type="button"
>
{testing
? '测试中…'
: activeTab === 'model'
? '保存并测试模型'
: agentRuntimeType === 'opencode'
? '保存并测试 OpenCode'
: '保存并测试 Continue'}
{saving ? '保存中…' : '保存设置'}
</button>
)}
<button
className="primary-button"
disabled={saving || testing}
onClick={() => void save()}
type="button"
>
{saving ? '保存中…' : '保存设置'}
</button>
</>
)}
</footer>
</>
)}
</footer>
)}
</section>
</div>
)
File diff suppressed because it is too large Load Diff
+25 -4
View File
@@ -800,8 +800,10 @@ export const knowledgeUrlImportSchema = z
export const knowledgeUpdateLibrarySchema = z
.object({
libraryId: knowledgeIdSchema,
graphEnabled: z.boolean(),
graphStrategy: z.enum(['rules', 'model', 'hybrid', 'ask'])
name: z.string().trim().min(1).max(120).optional(),
description: z.string().trim().max(1_000).optional(),
graphEnabled: z.boolean().optional(),
graphStrategy: z.enum(['rules', 'model', 'hybrid', 'ask']).optional()
})
.strict()
export const knowledgeEntityUpdateSchema = z
@@ -856,6 +858,21 @@ export type KnowledgeDocumentItem = {
error?: string
}
export type KnowledgeTaskItem = {
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 KnowledgeGraphNode = {
id: string
label: string
@@ -892,6 +909,7 @@ export type KnowledgeSnapshot = {
graphNodes: KnowledgeGraphNode[]
graphRelations: KnowledgeGraphRelation[]
evidence: KnowledgeEvidence[]
tasks?: KnowledgeTaskItem[]
}
export type KnowledgeSearchReference = {
@@ -1185,11 +1203,14 @@ export type DesktopApi = {
updateLibrary: (
libraryId: string,
update: {
graphEnabled: boolean
graphStrategy: 'rules' | 'model' | 'hybrid' | 'ask'
name?: string
description?: string
graphEnabled?: boolean
graphStrategy?: 'rules' | 'model' | 'hybrid' | 'ask'
}
) => Promise<void>
deleteLibrary: (libraryId: string) => Promise<void>
reextractGraph: (libraryId: string) => Promise<void>
selectFiles: (
libraryId: string,
graphStrategy?: 'rules' | 'model' | 'hybrid'
+1
View File
@@ -133,6 +133,7 @@ export const ipcChannels = {
knowledgeCreateLibrary: 'knowledge:library:create',
knowledgeUpdateLibrary: 'knowledge:library:update',
knowledgeDeleteLibrary: 'knowledge:library:delete',
knowledgeReextractGraph: 'knowledge:graph:reextract',
knowledgeSelectFiles: 'knowledge:source:select-files',
knowledgeSelectDirectory: 'knowledge:source:select-directory',
knowledgeImportPaths: 'knowledge:source:import-paths',