perf: reduce streaming render cost and refresh activity page on demand
- Segment streaming Markdown so completed blocks are reused and only the growing tail is re-parsed; fall back to whole-document parsing for document-wide constructs - Memoize conversation sidebar rows and cache per-conversation derived activity, ordering and pending approvals - Activity page loads on entry, refreshes every 15 s while visible, and has a single header refresh button - Include pending settings, model download source, device sharing and knowledge gateway changes in the working tree - Add Electron streaming input benchmark and regression tests
This commit is contained in:
+14
-7
@@ -57,7 +57,7 @@ records are listed separately and do not introduce another feature status.
|
||||
- [x] **Optional desktop task notifications**: A default-on switch under
|
||||
Platform Features / General immediately controls system task reminders.
|
||||
In-app notifications remain available independently; General settings group
|
||||
shortcuts, conversations and notifications, and workspace and downloads.
|
||||
shortcuts, conversations and notifications, and the working directory.
|
||||
- [x] **On-demand contextual help**: Supplementary page and settings
|
||||
explanations use shared title- or field-adjacent help controls with mouse
|
||||
and keyboard access. Important operation consequences and errors remain visible.
|
||||
@@ -152,7 +152,9 @@ records are listed separately and do not introduce another feature status.
|
||||
All five application rows support arrow and drag reordering regardless of enablement or pinning.
|
||||
Cards, the launcher, and the filtered sidebar share one persisted order. Knowledge
|
||||
is always enabled and pinned, with Open and reorder controls. Supervisor, Notes,
|
||||
and Local Inference Monitor have enablement and pinning settings. Supervisor uses
|
||||
and Local Inference Monitor have enablement and pinning settings. Device Sharing
|
||||
is optional and off by default during testing, with an enable switch but no pinning.
|
||||
Supervisor uses
|
||||
the existing Heartbeat identity and defaults to off when no preference is saved;
|
||||
explicit saved choices are preserved. Disabling it retains plans and history,
|
||||
blocks new reviews, and hides sidebar feedback; in-flight work may finish.
|
||||
@@ -171,8 +173,10 @@ records are listed separately and do not introduce another feature status.
|
||||
local-engine checks, and remaining platform/package coverage. Historical
|
||||
failures are not the current full-suite result. See the
|
||||
[validation progress](./docs/features/application-tool-navigation/progress.md).
|
||||
- [x] **Local device-sharing metadata** (implemented in source): Open Device Sharing
|
||||
from Apps or Application Center, save a service URL and device name, register the
|
||||
- [x] **Local device-sharing metadata** (implemented in source): Enable Device Sharing
|
||||
in Application Center first; it is off by default during testing. Saved explicit
|
||||
enablement is preserved. Disabling leaves the page without clearing catalog records
|
||||
or revoking publications. Open it from Apps or Application Center, save a service URL and device name, register the
|
||||
device, browse returned records, publish capability or knowledge metadata, and
|
||||
revoke this device ID's entries. The default service is `http://127.0.0.1:8787`.
|
||||
This unauthenticated local service uses IDs for identification only. Server source
|
||||
@@ -928,9 +932,12 @@ records are listed separately and do not introduce another feature status.
|
||||
- [x] **WeCom and DingTalk connections**: Main-only encrypted settings,
|
||||
read-only environment-variable overrides, connection tests, dynamic
|
||||
enable/disable, sender scope, and status diagnostics.
|
||||
- [x] **Managed local-model download source**: Platform Features / General
|
||||
selects ModelScope (default) or Hugging Face globally for future speech-input
|
||||
and OCR model downloads. A source without a complete verified file is
|
||||
- [x] **Managed local-model download source**: The Model Connections header shows
|
||||
the current source and a secondary button, right-aligned on the same row as the
|
||||
model-type tabs below Save. Its dialog selects ModelScope (default) or Hugging Face
|
||||
globally for future managed local-model downloads; changes save only on Confirm,
|
||||
Cancel leaves the saved source unchanged, and errors allow retry. Embedding, speech,
|
||||
and OCR source links open this dialog directly. A source without a complete verified file is
|
||||
explicitly unavailable and never silently falls back or combines files.
|
||||
- [x] **Optional local speech-model management**: Model weights are not bundled.
|
||||
Provides verified downloads, progress and cancellation, source links, ZIP or
|
||||
|
||||
+4
-4
@@ -23,7 +23,7 @@
|
||||
后台会话成功完成后,在本次客户端运行期间保留完成提示,直到对应聊天可见且加载完成;提示不跨重启保留。
|
||||
- [x] **会话置顶与完整历史**:常用会话可置顶,重启后保留。列表和搜索不再限于最近 100 个会话,连续发送不再截断超过 500 条的已保存历史;模型上下文限制仍有效,已丢失的历史无法恢复。
|
||||
置顶与非置顶分组按最近一条消息的创建时间排序,流式文本和工具进度更新不再反复改变位置。
|
||||
- [x] **可选桌面任务通知**:“平台功能 / 通用设置”提供默认开启、保存后立即生效的系统任务提醒开关,应用内提示独立保留;通用设置按快捷键、会话与通知、工作目录与下载分组。
|
||||
- [x] **可选桌面任务通知**:“平台功能 / 通用设置”提供默认开启、保存后立即生效的系统任务提醒开关,应用内提示独立保留;通用设置按快捷键、会话与通知、工作目录分组。
|
||||
- [x] **按需上下文帮助**:页面和设置中的补充说明使用标题或字段旁的共享帮助入口,支持鼠标与键盘;关键操作后果和错误提示保持可见。
|
||||
- [x] **紧凑会话控件**:输入选项集中在紧凑设置面板,Runtime、工作模式、发送和队列操作保持可用;会话搜索提供内嵌清除按钮,清除后恢复当前范围列表并把焦点返回输入框。
|
||||
- [x] **会话历史按需加载**:列表只加载轻量摘要,不再一次读入全部会话的过程元数据;已打开和活动会话保留完整详情,搜索、复制、导出、续聊和远程待答问题保持可用。图片合并不会重新发送未变化消息,已确认保存的空闲详情随现有视图缓存释放。详见[历史读取与保留规则](./docs/features/assistant-workbar/execution-history-storage.md#会话列表读取与前端保留)。
|
||||
@@ -36,8 +36,8 @@
|
||||
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
|
||||
- [x] **助手工作栏、多终端与可调布局**:工作栏开关位于明暗主题按钮旁;右侧工作栏使用持久“+”能力目录和应用 Tab,任务中心与工作区为不可关闭的单实例,浏览器可打开多个独立实例。任务中心可切换当前项目和所有项目,范围随布局保存;无项目任务在所有项目中标注“未绑定项目”。用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记编辑器的 AI 评论栏支持鼠标与键盘调宽;AI 评论栏记住显示状态及宽度,窄窗口下移至编辑器底部。
|
||||
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
|
||||
- [x] **应用中心与导航**:底部“应用”和“设置”为显示文字的等高按钮,中间使用弱竖分隔线。“应用”带向上箭头,点击后向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻筛选。点击应用行关闭菜单,本机推理监控打开保留底层工作区的独立 Modal,其余应用打开主内容区;底部“管理应用”使用 `LayoutGrid` 图标,打开标题仍为“应用中心”的可搜索列表及设置详情,布局与交互见 [UI 设计](./docs/features/application-tool-navigation/ui-design.md)。五个应用行均支持上下移动和拖动排序,不受启用或常驻状态限制;列表、菜单和经过过滤的侧栏共用一份保存顺序。知识库始终启用并常驻,提供“打开”和排序;监督者、魔法笔记和本机推理监控可设置启用与常驻。设备共享始终可从应用菜单打开,不提供启用或常驻开关。监督者沿用智能心跳身份,缺失启用设置时默认关闭,已明确保存的选择保留;关闭后保留计划和历史,拒绝新回顾并隐藏侧栏反馈,在途运行可完成。启用应用不会自动创建计划。旧应用顺序补齐默认项并保留已有相对顺序。设置持久化后的事件同步各入口,包括工具配置写入;重开中心刷新期间锁定修改,新快照覆盖迟到的旧读取。`local-inference` Modal 直接显示服务列表,不展示任务历史或外部连接;向量服务显示独立进程 CPU 与工作集内存,ASR/OCR 说明共享进程无法独立统计的原因。ASR 就绪状态未知,不从活动请求推断运行中。受支持的服务操作保留影响确认,失败后刷新列表并要求重新确认;TTS 显示不可用。取消中的推理在 Worker 确认或退出前仍计入活动任务及服务停止影响。实现已接入生产 App;验收记录分别列出完整 App、组件 fixture、真实本地引擎和剩余平台/安装包覆盖,不以历史失败代替当前全量结果,详见[验证进度](./docs/features/application-tool-navigation/progress.md)。
|
||||
- [x] **本地设备共享元数据**(源码已实现):从应用菜单或应用中心进入设备共享,保存服务地址和设备名称、注册本机、浏览服务返回的目录、发布能力或知识元数据并撤销本机 ID 的条目。默认服务为 `http://127.0.0.1:8787`。服务未认证,ID 只用于识别;服务器来源不上传或同步内容,知识搜索/读取/下载标志是独立声明,不提供正文访问或远程执行。详见[范围与验证](./docs/features/device-sharing/README.md)。
|
||||
- [x] **应用中心与导航**:底部“应用”和“设置”为显示文字的等高按钮,中间使用弱竖分隔线。“应用”带向上箭头,点击后向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻筛选。点击应用行关闭菜单,本机推理监控打开保留底层工作区的独立 Modal,其余应用打开主内容区;底部“管理应用”使用 `LayoutGrid` 图标,打开标题仍为“应用中心”的可搜索列表及设置详情,布局与交互见 [UI 设计](./docs/features/application-tool-navigation/ui-design.md)。五个应用行均支持上下移动和拖动排序,不受启用或常驻状态限制;列表、菜单和经过过滤的侧栏共用一份保存顺序。知识库始终启用并常驻,提供“打开”和排序;监督者、魔法笔记和本机推理监控可设置启用与常驻。设备共享在测试阶段默认关闭,提供启用开关,不提供常驻开关。监督者沿用智能心跳身份,缺失启用设置时默认关闭,已明确保存的选择保留;关闭后保留计划和历史,拒绝新回顾并隐藏侧栏反馈,在途运行可完成。启用应用不会自动创建计划。旧应用顺序补齐默认项并保留已有相对顺序。设置持久化后的事件同步各入口,包括工具配置写入;重开中心刷新期间锁定修改,新快照覆盖迟到的旧读取。`local-inference` Modal 直接显示服务列表,不展示任务历史或外部连接;向量服务显示独立进程 CPU 与工作集内存,ASR/OCR 说明共享进程无法独立统计的原因。ASR 就绪状态未知,不从活动请求推断运行中。受支持的服务操作保留影响确认,失败后刷新列表并要求重新确认;TTS 显示不可用。取消中的推理在 Worker 确认或退出前仍计入活动任务及服务停止影响。实现已接入生产 App;验收记录分别列出完整 App、组件 fixture、真实本地引擎和剩余平台/安装包覆盖,不以历史失败代替当前全量结果,详见[验证进度](./docs/features/application-tool-navigation/progress.md)。
|
||||
- [x] **本地设备共享元数据**(源码已实现):测试阶段默认关闭,先在应用中心启用,再从应用菜单或应用中心打开;已明确保存的启用选择保留,关闭会退出页面,但不清除目录或撤销已发布记录。可保存服务地址和设备名称、注册本机、浏览服务返回的目录、发布能力或知识元数据并撤销本机 ID 的条目。默认服务为 `http://127.0.0.1:8787`。服务未认证,ID 只用于识别;服务器来源不上传或同步内容,知识搜索/读取/下载标志是独立声明,不提供正文访问或远程执行。详见[范围与验证](./docs/features/device-sharing/README.md)。
|
||||
- [ ] **私有化应用市场**(规划中):在应用管理基础上提供组织自己的目录与应用分发。当前只管理内置应用,尚无市场浏览或安装能力,详见 [FR-15](./docs/features/application-tool-navigation/prd.md#fr-15-私有化市场与-yaml-交换后续)。
|
||||
- [ ] **通用助手工作栏与执行空间后续能力**(规划中):在现有工作栏和多终端基础上继续加入更完整的执行监督、统一 Runtime 监控、受管进程、可固定目标的工作区/浏览器/成果实例、底部停靠与独立窗口。Task Center 保持 Task 的单例索引,附件与知识库继续由会话输入区管理,未来的记忆与历史执行上下文归入关联 Task。详见 [Feature PRD](./docs/features/assistant-workbar/prd.md)。
|
||||
- [ ] **ShareServer Office 协同编辑**(设计中):通过可选 ShareServer 集成 ONLYOFFICE Docs,在助手工作栏中以文件名打开多个文档 Tab,支持 DOCX、XLSX、PPTX 的人工编辑、保存、撤销、源文件冲突保护和后续 AI 选区修改。Office 正文会由所选 ShareServer 和编辑引擎处理;Desktop 不内置或启动 Document Server,未配置服务时不宣称离线编辑。详见[设计文档](./docs/features/office-document-editing/README.md)。
|
||||
@@ -146,7 +146,7 @@
|
||||
- [x] **微信 ClawBot 扫码与媒体**:通过独立 Sidecar 完成本机扫码、验证码、加密凭据和文字收发;支持个人微信私聊图片与文件,单条消息最多 4 个附件、解密后合计不超过 12MB。
|
||||
- [x] **微信安全回传**:支持返回当前任务生成的图片,或在用户明确要求时将本次最终文本生成为 Markdown 附件;不自动读取或发送已有工作区文件。
|
||||
- [x] **企业微信与钉钉连接**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。
|
||||
- [x] **受管本地模型下载源**:在“平台功能 / 通用设置”中为后续语音输入与 OCR 模型下载全局选择 ModelScope(默认)或 Hugging Face;所选来源缺少完整已验证文件时明确不可用,不静默换源或混合文件。
|
||||
- [x] **受管本地模型下载源**:“模型连接”页头在模型类型页签同行右侧、保存操作下方显示当前来源与次按钮。弹窗为后续受管本地模型下载全局选择 ModelScope(默认)或 Hugging Face,确认才保存,取消保持已保存来源不变,错误可重试;向量、语音和 OCR 的更换下载源入口直接打开该弹窗。所选来源缺少完整已验证文件时明确不可用,不静默换源或混合文件。
|
||||
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、ZIP 或本地目录导入、切换和删除。
|
||||
- [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。
|
||||
- [x] **版本检查与镜像节点**:在“关于与更新”中选择 GitHub(默认)或镜像节点;手动检查、启动时检查和下载页使用同一选择,并只读取固定可信的发布索引,不自动下载或安装。
|
||||
|
||||
+4
-3
@@ -337,7 +337,7 @@
|
||||
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
|
||||
单行截断,不能撑宽或挤压侧栏。
|
||||
- 侧栏底部提供“应用 / Apps”和“设置 / Settings”两个横向等高 `nav-item` 按钮,均显示图标和文字,中间使用弱竖分隔线。“应用”带向下箭头,点击后向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻状态筛选;点击行关闭菜单,知识库、智能心跳和魔法笔记在主内容区打开页面。本机推理监控从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。菜单底部“管理应用”使用 `LayoutGrid` 图标,打开既有整窗管理 Modal,标题仍为“应用中心 / Application Center”,共同应用设置沿用设置 Modal。
|
||||
五个内置应用的管理列表、菜单和侧栏共用保存顺序,菜单按启用过滤,侧栏按启用且常驻过滤。知识库始终启用并常驻,可调整位置;管理 Modal 提供“打开”和排序,不提供启用、常驻开关或通用空设置,标签为“始终显示”(英文 `Always shown`)。监督者沿用智能心跳身份,可修改启用和常驻,执行边界见[监督者逻辑](./docs/features/conversation-supervision/logic-design.md#应用启停与执行)。魔法笔记、本机推理监控同样可修改启用和常驻。设备共享始终可从应用菜单及管理 Modal 打开,标签为“内置”,提供排序,不提供启用或常驻开关;在主内容区打开元数据页面,规则见[设备共享 UI](./docs/features/device-sharing/ui-design.md#当前首期页面)。当前对话、运行记录和设置保持固定系统位置,不参与应用排序。
|
||||
五个内置应用的管理列表、菜单和侧栏共用保存顺序,菜单按启用过滤,侧栏按启用且常驻过滤。知识库始终启用并常驻,可调整位置;管理 Modal 提供“打开”和排序,不提供启用、常驻开关或通用空设置,标签为“始终显示”(英文 `Always shown`)。监督者沿用智能心跳身份,可修改启用和常驻,执行边界见[监督者逻辑](./docs/features/conversation-supervision/logic-design.md#应用启停与执行)。魔法笔记、本机推理监控同样可修改启用和常驻。设备共享在测试阶段默认关闭,标签为“可选”及启用状态,提供启用设置和排序,不提供常驻开关;启用后可从应用菜单及管理 Modal 在主内容区打开元数据页面,关闭时退出该页,规则见[设备共享 UI](./docs/features/device-sharing/ui-design.md#当前首期页面)。当前对话、运行记录和设置保持固定系统位置,不参与应用排序。
|
||||
设置持久化后的事件同步各入口及工具配置写入;重开中心刷新期间锁定修改,新快照不被旧读取覆盖。文件、终端、浏览器和资源监控只通过右侧工作栏访问,不在左侧复制入口。
|
||||
管理 Modal 始终使用紧凑单列横向列表,纵向对应保存顺序;左侧为图标、名称及简短用途,右侧为打开、可用的设置及上下移动操作,行间使用弱分隔线,不加卡片外框。窄窗口操作换行且不溢出,保留共享键盘焦点环。可选应用关闭时禁用“打开”。启用、常驻位于紧凑设置详情;五行均提供上下移动及拖动排序,不受启用或常驻影响,箭头按完整顺序判断边界。搜索时禁用拖动,箭头仍按完整顺序移动,不丢失未命中项。尺寸、返回与滚动重置规则见[应用中心 UI 设计](./docs/features/application-tool-navigation/ui-design.md)。已有稳定设置入口时,不在顶栏重复提供同一入口。
|
||||
- 本机推理监控 Modal 复用整窗 Portal、背景隔离和焦点循环;停止、重启及释放使用其上方的紧凑影响确认。Escape 只关闭最上层,操作提交期间禁止关闭及重复提交;焦点恢复与尺寸见[本机推理监控 UI 规则](./docs/features/application-tool-navigation/ui-design.md#91-本机推理管理)。
|
||||
@@ -842,14 +842,15 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
“打开智能心跳”导航,不复制创建、暂停、恢复或删除表单。
|
||||
- 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。
|
||||
- “模型连接”的认证字段按“认证方式 → API Key → 凭据状态”排列;只有选择 API Key 时显示输入框。已保存的 API Key 属于该连接,修改模型服务地址或临时选择“无需认证”不得自动清除,也不得要求重新输入;仅由明确的“清除凭据”或“删除连接”操作移除。
|
||||
- “模型连接”页头在保存操作下方,将模型类型页签与全局模型下载源入口放在同一行;当前来源文字与“模型下载源”次按钮整体右对齐,窄窗口保持同行,空间不足时导航行可横向滚动。按钮打开挂载到 `document.body` 的 Portal 弹窗,复用共享 Modal 焦点管理;弹窗内使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片,选中状态同时依靠 Radio、边框和背景表达。Radio 只修改草稿,点击“确认”才保存;取消或关闭不改变已保存来源。读取失败提供重试并禁用确认,不以默认值伪装已保存选择;保存失败保留草稿并允许确认重试,保存期间禁止重复提交和关闭。向量、语音和 OCR 的更换下载源入口直接打开模型连接页的同一弹窗,跨分类时沿用未保存修改保护。详细规则见[模型下载源设计](docs/architecture/model-download-source-design.md#7-模型下载源界面)。
|
||||
- 设置中的连接列表与详情共用 `.model-connection-manager`,按容器可用宽度响应:不超过 `640px` 时上下排列,列表内部滚动高度最多 `180px`;更宽时恢复左右排列。LLM、向量连接和角色编辑复用此规则,不以整窗宽度代替表单宽度。接口协议的中英文当前值须完整可读。回归范围见[窄容器布局修复记录](docs/quality/responsive-controls-review-2026-09-27.md)。
|
||||
- “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 HTTP 状态、模型列表或健康检查响应不算测试成功。界面持续说明该操作可能产生少量服务商用量费用,成功反馈明确写为“真实生成测试通过”。
|
||||
- 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。
|
||||
- “平台功能 / 通用设置”同时提供默认开启的“在会话中渲染 HTML”持久 Switch。说明必须
|
||||
明确该能力只生成禁用脚本、联网、表单提交和窗口操作的静态预览;保存成功后当前会话立即
|
||||
更新,保存失败时保留最后确认状态。
|
||||
- 通用设置按快捷唤起、会话与通知、工作目录与下载分组,复用现有卡片。“桌面通知”为默认开启的持久 Switch,保存后立即控制窗口未聚焦时的任务完成、失败和取消系统通知,关闭不影响应用内通知。旧配置缺少该字段时默认开启,明确关闭后重启仍保持关闭;保存失败保留最后确认状态并显示应用内错误通知。
|
||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“远程项目”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64、Linux arm64、macOS arm64 Agent 包清单分成独立卡片;每张卡片与下载进度按平台和架构区分,不能把两个 arm64 目标合并。清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
|
||||
- 通用设置按快捷唤起、会话与通知、工作目录分组,复用现有卡片。“桌面通知”为默认开启的持久 Switch,保存后立即控制窗口未聚焦时的任务完成、失败和取消系统通知,关闭不影响应用内通知。旧配置缺少该字段时默认开启,明确关闭后重启仍保持关闭;保存失败保留最后确认状态并显示应用内错误通知。
|
||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“远程项目”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64、Linux arm64、macOS arm64 Agent 包清单分成独立卡片;每张卡片与下载进度按平台和架构区分,不能把两个 arm64 目标合并。清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。
|
||||
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、同行帮助和原生单选下拉框放在同一行,用途说明收进 `InlineHelp`,并复用设置表单的统一控件样式;关闭启动检查后仍可选择更新源,手动检查继续使用当前选择。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。
|
||||
- “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图;另提供默认关闭的原生 Checkbox,让用户主动附加最近桌面诊断摘要。Checkbox 附近必须持续说明摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息,不含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。勾选时为诊断摘要预留描述字符预算,超长草稿必须保留并显示字段错误,不得静默截断。失败时保留草稿、勾选状态和请求编号并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。
|
||||
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
| 目标平台 | Windows、macOS、Linux,x64 与 arm64 |
|
||||
| 相关基线 | [统一界面设计系统](../../UI-DESIGN.md)、[文档解析与本地 OCR](../features/document-processing/prd.md)、[本地文本向量模型与连接设计](../features/knowledge-base/local-embedding-technical-design.md)、[全双工实时语音交互设计](../features/full-duplex-voice/technical-design.md) |
|
||||
|
||||
本文定义“设置 → 平台功能”的二级页签结构,以及所有 GoodBuddy 托管本地模型共同使用的
|
||||
“模型下载源”设置。首期下载源为 ModelScope 和 Hugging Face,默认使用 ModelScope。
|
||||
本文定义“设置 → 平台功能”的二级页签结构,以及位于“设置 → 模型连接”、所有 GoodBuddy
|
||||
托管本地模型共同使用的“模型下载源”设置。首期下载源为 ModelScope 和 Hugging Face,默认使用 ModelScope。
|
||||
|
||||
模型下载源是用户明确选择的供应链路径。当前来源不可用或没有所选模型时,GoodBuddy
|
||||
必须明确失败并提供设置入口,**不能静默切换到另一个下载源。**
|
||||
@@ -25,7 +25,8 @@
|
||||
1. “平台功能”保留现有一级设置分类,在分类内部增加共享 `PageTabs`:
|
||||
- **通用设置**
|
||||
- **远程项目**
|
||||
2. “通用设置”首个功能为“模型下载源”。
|
||||
2. “模型下载源”位于“模型连接”页头,与模型类型页签同一行右对齐,在保存操作下方;
|
||||
显示当前来源和次按钮,点击后在弹窗中选择并确认保存。
|
||||
3. 首期提供两个互斥选项:
|
||||
- `modelscope`,用户文案为 **ModelScope**,默认值。
|
||||
- `hugging-face`,用户文案为 **Hugging Face**。
|
||||
@@ -47,8 +48,8 @@
|
||||
同一模型包的部分文件从 ModelScope 下载、部分文件从 Hugging Face 下载。
|
||||
8. 下载任务启动时冻结来源。任务运行期间切换全局设置,不改变、重启或迁移该任务;新的
|
||||
下载使用新来源。
|
||||
9. 当前来源失败时,不自动重试另一个来源。错误可以提供“前往通用设置更换下载源”,由
|
||||
用户明确选择。
|
||||
9. 当前来源失败时,不自动重试另一个来源。向量、语音和 OCR 的更换下载源入口直接打开
|
||||
“模型连接”页的下载源弹窗,由用户明确选择并确认。
|
||||
10. 魔法笔记设置由应用中心与笔记页共享,不在全局设置中重复提供入口。
|
||||
|
||||
---
|
||||
@@ -59,10 +60,11 @@
|
||||
|
||||
“平台功能”现已使用两个二级页签:
|
||||
|
||||
- “通用设置”承载全局模型下载源。
|
||||
- “通用设置”承载快捷唤起、会话与通知、工作目录设置。
|
||||
- “远程项目”承载远程项目开关与 Agent 包管理。
|
||||
|
||||
页签复用共享 `PageTabs`,默认打开“通用设置”,不会把当前页签写入应用设置。
|
||||
全局模型下载源由“模型连接”页头提供入口,布局和弹窗行为见第 7 节。
|
||||
|
||||
### 2.2 受管模型下载
|
||||
|
||||
@@ -99,7 +101,7 @@
|
||||
- 切换来源时不影响已安装、已选择或正在使用的模型。
|
||||
- 下载失败时知道实际使用了哪个来源。
|
||||
- 所选来源没有模型时得到明确说明,并可以主动前往设置切换。
|
||||
- 在“平台功能”的“通用设置”中选择模型下载源。
|
||||
- 在“模型连接”页头打开模型下载源弹窗,选择后确认保存。
|
||||
|
||||
### 3.2 产品目标
|
||||
|
||||
@@ -163,9 +165,12 @@
|
||||
|
||||
```text
|
||||
设置
|
||||
├─ 模型连接
|
||||
│ └─ 页头:模型类型页签同行右侧的当前来源与“模型下载源”按钮
|
||||
│ └─ 模型下载源弹窗
|
||||
└─ 平台功能
|
||||
├─ 通用设置
|
||||
│ └─ 模型下载源
|
||||
│ └─ 快捷唤起、会话与通知、工作目录
|
||||
└─ 远程项目
|
||||
├─ 远程项目开关
|
||||
└─ Agent 包管理
|
||||
@@ -221,21 +226,20 @@
|
||||
|
||||
---
|
||||
|
||||
## 7. 通用设置界面
|
||||
## 7. 模型下载源界面
|
||||
|
||||
### 7.1 布局
|
||||
|
||||
通用设置按快捷唤起、会话与通知、工作目录与下载分成三张卡片。模型下载源位于
|
||||
“工作目录与下载”卡片内的工作目录输入之后;以下仅展示下载源控件,完整分组及通知
|
||||
开关行为见 [UI 设计规范](../../UI-DESIGN.md#138-设置中心)。
|
||||
“模型连接”页头在保存操作下方,将模型类型页签与下载源控件放在同一行:页签居左,
|
||||
“当前来源:ModelScope”和“模型下载源”次按钮整体右对齐。窄窗口保持同行,空间不足时
|
||||
导航行可横向滚动。点击按钮打开弹窗;通用设置的工作目录卡片不再提供下载源控件。共享布局规则见
|
||||
[UI 设计规范](../../UI-DESIGN.md#138-设置中心)。
|
||||
|
||||
```text
|
||||
平台功能
|
||||
管理通用平台行为与可选工作区能力
|
||||
模型连接 [保存]
|
||||
[文本] [向量] [重排] [语音] 当前来源:ModelScope [模型下载源]
|
||||
|
||||
[ 通用设置 ] [ 远程项目 ]
|
||||
|
||||
┌ 模型下载源 ────────────────────────────────────────────┐
|
||||
┌ 模型下载源(弹窗)───────────────────────────────── [×]┐
|
||||
│ 选择 GoodBuddy 托管本地模型后续下载使用的平台。 │
|
||||
│ 已安装模型和 ZIP 导入不受影响。 │
|
||||
│ │
|
||||
@@ -244,19 +248,19 @@
|
||||
│ │
|
||||
│ ○ Hugging Face │
|
||||
│ 适合可以稳定访问 Hugging Face 的网络环境 │
|
||||
│ │
|
||||
│ [取消] [确认] │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
模型下载源保存属于即时设置,不增加与来源选项竞争的
|
||||
大型“保存全部”按钮。
|
||||
弹窗通过 Portal 挂载到 `document.body`,复用共享 Modal 样式、背景隔离和焦点管理。
|
||||
模型连接页头的保存操作与下载源弹窗的“确认”各自保存对应设置。
|
||||
|
||||
### 7.2 控件
|
||||
|
||||
来源选项包含说明和未来可能的不可用原因,因此使用语义化 Radio Group,而不是原生
|
||||
`select` 或 `SegmentedControl`:
|
||||
|
||||
- `fieldset` + `legend` 表达“模型下载源”。
|
||||
- 弹窗内使用 `fieldset` + 持久的屏幕阅读器 `legend` 表达“模型下载源”。
|
||||
- 每个选项使用 Radio 和整行可点击卡片。
|
||||
- 当前项同时显示选中 Radio、强调边框和选中背景。
|
||||
- 不能只用站点 Logo 或颜色表达选择。
|
||||
@@ -285,12 +289,16 @@
|
||||
|
||||
### 7.4 保存交互
|
||||
|
||||
- 用户选择另一个 Radio 后立即调用应用设置更新。
|
||||
- 保存期间禁用两个选项,保留最后确认值。
|
||||
- 成功后更新当前选择,并通过应用通知提示:
|
||||
- 打开弹窗时以已保存来源初始化草稿;选择 Radio 只修改草稿,不调用应用设置更新。
|
||||
- 点击“确认”才保存;草稿与已保存来源相同时直接关闭,不重复写入。
|
||||
- 取消、关闭按钮、Escape 或点击遮罩均丢弃草稿,已保存来源不变。
|
||||
- 保存期间禁用选项、确认、取消和关闭,阻止重复提交及遮罩、Escape 关闭。
|
||||
- 成功后更新页头当前来源并关闭弹窗,通过应用通知提示:
|
||||
`模型下载源已切换为 Hugging Face。`
|
||||
- 失败时恢复最后确认值,在 Radio Group 附近显示可重试错误。
|
||||
- 保存失败时保留弹窗和草稿,已保存来源不变;就地显示错误,允许再次点击“确认”重试。
|
||||
- 读取失败时显示错误和“重试”,禁用确认,不以默认值伪装为已保存来源。
|
||||
- 同一个失败不再同时显示页面横幅和全局错误通知。
|
||||
- 弹窗打开后聚焦关闭按钮,Tab 在弹窗内循环;关闭后焦点回到页头“模型下载源”按钮。
|
||||
|
||||
来源变化不触发:
|
||||
|
||||
@@ -631,7 +639,7 @@ resolveModelDownloadPackage(files, source)
|
||||
|
||||
### 12.1 来源状态
|
||||
|
||||
语音和 OCR 的当前模型详情各保留一个全局下载源标签。向量内置连接不重复显示平台功能中
|
||||
语音和 OCR 的当前模型详情各保留一个全局下载源标签。向量内置连接不重复显示模型连接页头中
|
||||
已经明确的全局来源,也不显示固定本地安装路径;下载仍使用已保存的全局来源。
|
||||
|
||||
所有模型管理界面都显示当前模型是否可下载、活动操作的进度与取消入口,以及已安装模型
|
||||
@@ -649,10 +657,12 @@ resolveModelDownloadPackage(files, source)
|
||||
|
||||
```text
|
||||
Hugging Face 暂不提供此模型的完整已验证文件。
|
||||
[前往通用设置]
|
||||
[模型下载源]
|
||||
```
|
||||
|
||||
不能显示可点击“下载”后才告诉用户缺少某个文件。
|
||||
向量、语音和 OCR 的“模型下载源”入口均导航到“模型连接”并直接打开同一弹窗。
|
||||
跨分类导航沿用设置中心的未保存修改确认,用户取消离开时保留原表单。
|
||||
|
||||
### 12.2 已安装模型
|
||||
|
||||
@@ -697,7 +707,7 @@ ZIP 是来源无关的离线迁移方式:
|
||||
- `正在从 {{source}} 下载`
|
||||
- `打开 {{source}} 模型仓库`
|
||||
- `当前下载源暂不提供此模型`
|
||||
- `前往通用设置更换下载源`
|
||||
- `模型下载源`(打开下载源弹窗)
|
||||
|
||||
不再写死:
|
||||
|
||||
@@ -743,7 +753,7 @@ ZIP 是来源无关的离线迁移方式:
|
||||
失败状态提供:
|
||||
|
||||
1. 重试当前来源。
|
||||
2. 前往“平台功能 → 通用设置”。
|
||||
2. 打开“模型连接”的模型下载源弹窗,选择并确认保存。
|
||||
3. 取消。
|
||||
|
||||
用户切换来源后需要重新点击下载。不能在设置保存后自动恢复上一失败任务。
|
||||
@@ -810,7 +820,7 @@ ModelScope 和 Hugging Face 都可能跳转到文件存储或 CDN。每个来源
|
||||
|
||||
| 设置 | 位置 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| 模型下载源 | 平台功能 → 通用设置 | GoodBuddy 托管本地模型 |
|
||||
| 模型下载源 | 模型连接页头 → 模型下载源弹窗 | GoodBuddy 托管本地模型 |
|
||||
| 检查更新源 | 关于与更新 | GoodBuddy 版本检查和下载页 |
|
||||
|
||||
切换任一设置不得修改另一个设置。用户选择 ModelScope 不表示应用更新使用镜像节点;选择
|
||||
@@ -850,9 +860,9 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
|
||||
| 场景 | 行为 |
|
||||
| --- | --- |
|
||||
| 读取设置失败 | 显示平台功能阻塞错误,不猜测或写入来源 |
|
||||
| 保存来源失败 | 保留上一个确认值,显示就地错误 |
|
||||
| 当前来源无完整模型包 | 下载按钮禁用,显示前往通用设置 |
|
||||
| 读取设置失败 | 页头显示读取失败,弹窗提供就地重试并禁用确认,不猜测或写入来源 |
|
||||
| 保存来源失败 | 已保存来源不变,保留弹窗和草稿,显示就地错误并允许确认重试 |
|
||||
| 当前来源无完整模型包 | 下载按钮禁用,提供直接打开模型连接页下载源弹窗的入口 |
|
||||
| 当前来源网络不可用 | 当前下载失败或允许同来源重试,不请求另一来源 |
|
||||
| 当前来源返回 404 | 显示来源缺少固定文件,不请求另一来源 |
|
||||
| 重定向 Host 不受信任 | 立即失败并删除本次暂存 |
|
||||
@@ -880,15 +890,19 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
- 更高未知版本拒绝,不覆盖文件。
|
||||
- 并发设置更新保持完整 JSON。
|
||||
|
||||
### 17.2 平台功能界面
|
||||
### 17.2 设置界面
|
||||
|
||||
- 默认选中“通用设置”Tab。
|
||||
- Tab 使用 `tablist`、`tab`、`tabpanel` 和 `aria-selected`。
|
||||
- 方向键、Home、End 和焦点恢复正确。
|
||||
- 切换平台功能页签不改变任何设置,全局设置不显示魔法笔记入口。
|
||||
- 模型连接页头的当前来源与次按钮位于模型类型页签同一行右侧、保存操作下方;通用设置不再显示下载源控件。
|
||||
- Radio Group 具有持久 Label。
|
||||
- 选择来源时保存一次,保存期间防止重复提交。
|
||||
- 保存失败恢复原值并保留可重试错误。
|
||||
- 选择 Radio 只更新草稿,确认后保存一次;未改变来源时确认不写入。
|
||||
- 取消、关闭、Escape 和遮罩关闭不改变已保存来源;保存期间禁止关闭及重复提交。
|
||||
- 保存失败保留草稿并允许确认重试;读取失败提供重试且禁用确认。
|
||||
- 弹窗使用 body Portal、焦点循环和关闭后焦点恢复。
|
||||
- 向量、语音和 OCR 的更换下载源入口直接打开模型连接页弹窗,跨分类时保留离开保护。
|
||||
- 窄窗口不隐藏页签或来源名称。
|
||||
|
||||
### 17.3 目录
|
||||
@@ -939,7 +953,7 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
- 新增 `ModelDownloadSource` Schema 和默认值。
|
||||
- 将应用设置版本升级并迁移旧版本。
|
||||
- 在“平台功能”中增加 `PageTabs`。
|
||||
- 增加“通用设置”与模型下载源 Radio Group。
|
||||
- “通用设置”保留平台行为;模型下载源已移至模型连接页头,通过弹窗 Radio 草稿和确认保存。
|
||||
- 魔法笔记卡已迁入应用共同设置,全局页签已移除,见第 8 节。
|
||||
|
||||
### 阶段 2:共享目录和来源解析(已完成)
|
||||
@@ -971,7 +985,9 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
|
||||
- “平台功能”显示“通用设置”和“远程项目”两个可访问页签。
|
||||
- 默认打开“通用设置”。
|
||||
- “通用设置”提供 ModelScope 和 Hugging Face,默认 ModelScope。
|
||||
- “模型连接”页头在模型类型页签同行右侧、保存操作下方显示当前来源及“模型下载源”次按钮。
|
||||
- 下载源弹窗提供 ModelScope 和 Hugging Face,默认 ModelScope;Radio 只编辑草稿,确认才保存,取消不改变已保存来源。
|
||||
- 读取与保存失败均可就地重试;向量、语音和 OCR 的更换下载源入口直接打开该弹窗。
|
||||
- 来源选择说明影响范围,并明确已安装模型、ZIP、Ollama 和应用更新不受影响。
|
||||
- 切换来源不会下载、删除、切换或重新验证任何已安装模型。
|
||||
- 当前来源没有模型时,在点击下载前即可看到不可用原因。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
智能心跳、魔法笔记和本机推理监控可通过应用管理控制启用和常驻。底部“应用”向上展开轻量锚定菜单,列出已启用应用;底部“管理应用”打开既有“应用中心”管理 Modal。系统工具由右侧工作栏 Tab 承载。应用商店是后续功能,首期不提供
|
||||
商店入口、推荐位或安装流程。
|
||||
|
||||
设备共享作为第五项追加到原有应用顺序,始终可从菜单和管理 Modal 打开,不提供启用、常驻
|
||||
设备共享作为第五项追加到原有应用顺序,测试阶段默认关闭,启用后可从菜单和管理 Modal 打开,不提供常驻
|
||||
开关;服务地址与设备名称在其页面保存。首期仅支持本地服务的设备注册与元数据目录,详见
|
||||
[设备共享](../device-sharing/README.md)。
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
2. 运行记录不进入工具注册表、应用显隐设置或插件市场。
|
||||
3. 文件浏览器、终端、浏览器和资源监控始终是系统工具,不能卸载或被插件替换。
|
||||
4. 左下角提供应用中心和独立固定设置入口;文件、终端、浏览器、资源监控只通过右侧工作栏 Tab 和“+”访问,不进入应用中心。
|
||||
5. 五个内置应用共享保存顺序。知识库始终启用并常驻,位置可调整;智能心跳、魔法笔记、本机推理监控可独立设置启用和常驻。设备共享始终可从应用菜单打开,不常驻侧栏,也不提供启停开关。取消常驻不关闭功能,关闭不删除数据或导航偏好。
|
||||
5. 五个内置应用共享保存顺序。知识库始终启用并常驻,位置可调整;智能心跳、魔法笔记、本机推理监控可独立设置启用和常驻。设备共享在测试阶段默认关闭,提供启用开关,启用后可从应用菜单打开,不常驻侧栏。关闭设备共享时退出其页面,不清除目录或撤销已发布记录。取消常驻不关闭功能,关闭不删除数据或导航偏好。
|
||||
6. 入口不能扩大执行空间、文件、PTY、网络或浏览器权限。
|
||||
7. 应用中心始终可达,点击向上展开无模态遮罩的锚定菜单,只列已启用应用,不使用打开历史或常驻过滤。菜单底部“管理应用”打开既有 Modal,关闭的可选应用在其中仍可搜索、配置和重新开启。
|
||||
8. 所有入口和新请求的功能判断读取同一份保存成功的权威设置;保存中的目标值不提前生效。
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
- 用户从侧栏底部进入应用中心,查找、打开和配置内置应用。
|
||||
- 主导航承载应用入口,本机推理监控打开 Modal,其余内置应用打开完整页面;辅助工具由工作栏承载。
|
||||
- 运行记录始终可发现,不受应用显示设置和插件状态影响。
|
||||
- 五个内置应用均支持排序;智能心跳、魔法笔记和本机推理监控可独立设置启用与常驻,知识库始终显示,设备共享始终可从应用菜单打开且不常驻侧栏。未常驻的已启用应用仍可从应用菜单打开。
|
||||
- 五个内置应用均支持排序;智能心跳、魔法笔记和本机推理监控可独立设置启用与常驻,知识库始终显示,设备共享在测试阶段默认关闭,可启用但不常驻侧栏。未常驻的已启用应用仍可从应用菜单打开。
|
||||
- 为插件应用和插件工具保留明确的入口契约,不提前建设插件安装能力。
|
||||
|
||||
## 4. 非目标
|
||||
@@ -127,13 +127,13 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止
|
||||
|
||||
### FR-11 应用导航设置
|
||||
|
||||
管理 Modal 的五个应用行均提供上下移动和拖动排序,不受启用或常驻状态限制;管理列表、菜单和侧栏共用一份顺序。箭头边界按完整顺序计算,搜索过滤不丢失隐藏项。智能心跳、魔法笔记和本机推理监控另提供应用设置、常驻管理;知识库与设备共享提供“打开”和排序,不添加通用空设置页或开关。原工作区配置入口及心跳单条计划 enabled 保持不变。设置中“启用应用”与“常驻左侧菜单”分别保存,
|
||||
管理 Modal 的五个应用行均提供上下移动和拖动排序,不受启用或常驻状态限制;管理列表、菜单和侧栏共用一份顺序。箭头边界按完整顺序计算,搜索过滤不丢失隐藏项。智能心跳、魔法笔记和本机推理监控另提供应用设置、常驻管理;知识库提供“打开”和排序,不添加通用空设置页或开关。设备共享提供启用设置、打开和排序,不提供常驻。原工作区配置入口及心跳单条计划 enabled 保持不变。设置中“启用应用”与“常驻左侧菜单”分别保存,
|
||||
修改即时提交;请求期间串行处理并禁用会修改同一配置的控件,失败就地反馈。
|
||||
魔法笔记设置统一从应用中心对应行进入,笔记页与全局设置不再提供重复入口。持久化成功后的设置变更同步覆盖工具配置写入;每次重开中心先刷新并锁定修改,较新的确认快照优先于迟到的读取结果。
|
||||
|
||||
### FR-12 偏好持久化
|
||||
|
||||
五个应用的顺序及可选应用启用、常驻按设备持久化,重启后恢复。默认顺序为知识库、智能心跳、魔法笔记、本机推理监控、设备共享。心跳应用默认关闭,其余应用默认启用;心跳和魔法笔记默认常驻,本机推理监控默认不常驻,设备共享不提供常驻开关;
|
||||
五个应用的顺序及可选应用启用、常驻按设备持久化,重启后恢复。默认顺序为知识库、智能心跳、魔法笔记、本机推理监控、设备共享。心跳和测试阶段的设备共享默认关闭,其余应用默认启用;心跳和魔法笔记默认常驻,本机推理监控默认不常驻,设备共享不提供常驻开关;
|
||||
魔法笔记待办数量默认显示,评论方式与形式沿用现有默认值。迁移保留已保存的启用、常驻和排序选择,
|
||||
旧魔法笔记入口开关绑定业务启用值,迁为“启用应用”并保留原值;常驻使用独立配置,
|
||||
只有已发布的独立显隐偏好才映射到常驻,不能由常驻推导业务启停。旧可选应用顺序补入知识库、智能心跳默认位置并保留已有相对顺序,具体补齐规则见[技术设计](./technical-design.md#43-持久化与-ipc)。`heartbeatEnabled` 的缺失值与保存值按[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)处理;不读取未发布草案中的 `knowledgeEnabled`。
|
||||
@@ -173,7 +173,7 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止
|
||||
- 主导航能明确区分固定系统入口和可配置内置应用。
|
||||
- 运行记录在所有应用显隐组合下保持可见并可打开。
|
||||
- 左下角不再用“本地工作区”与系统架构占据主要空间。
|
||||
- 应用菜单只列已启用应用,点击行关闭菜单;本机推理监控打开保留底层工作区的 Modal,其余应用进入主内容区。“管理应用”打开可搜索的既有 Modal。五个应用均可排序,知识库与设备共享始终可用,三个可选应用可管理启用与常驻,首期无商店入口。
|
||||
- 应用菜单只列已启用应用,点击行关闭菜单;本机推理监控打开保留底层工作区的 Modal,其余应用进入主内容区。“管理应用”打开可搜索的既有 Modal。五个应用均可排序,知识库始终可用,四个可选应用可管理启用,其中设备共享不提供常驻,首期无商店入口。
|
||||
- 系统工具仅从右侧 Tab 与“+”访问;选择 Tab 不创建或改绑实例,“+”可显式创建额外浏览器或终端。
|
||||
- 资源监控以 2 秒间隔更新,保留不超过 150 个样本;面板不活动、工作栏收起或窗口隐藏、
|
||||
最小化后停止请求新样本。
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
## 2026-10-01:设备共享入口
|
||||
|
||||
新增 `device-sharing`,应用菜单及管理 Modal 均进入主内容区页面;管理行可排序,不提供启用
|
||||
或常驻开关。旧顺序通过现有存储归一化追加新项,保留原设置;权威写入要求五项完整顺序。
|
||||
新增 `device-sharing`,启用后应用菜单及管理 Modal 均进入主内容区页面;管理行可排序。
|
||||
首期初始实现没有启用开关,同日后续改为测试阶段默认关闭、可手动启用,不提供常驻开关。
|
||||
旧顺序通过现有存储归一化追加新项,保留原设置;权威写入要求五项完整顺序。
|
||||
ApplicationCenter、ApplicationMenu、设置迁移以及 App 入口/排序回归已通过,真实 Electron
|
||||
从应用中心进入页面并完成后端流程。精确命令、完整范围及全仓测试记录见
|
||||
[设备共享实施进度](../device-sharing/progress.md)。下方历史四应用记录保留原验证时的范围。
|
||||
|
||||
@@ -82,7 +82,7 @@ type BuiltInApplicationDefinition = {
|
||||
|
||||
应用中心调用既有 `navigateFromSidebar` 或同一页面导航函数,沿用未保存离开检查和焦点
|
||||
策略。打开前检查确认的启用值;未常驻但已启用的应用不附带常驻写入。本机推理监控从常驻入口、菜单及管理 Modal 均经 `setView('local-inference')` 打开独立 Modal,保留底层工作区与设置草稿;其他应用继续页面导航。
|
||||
`ApplicationMenu` 按五个应用共用的持久化顺序派生清单,仅按启用过滤,不读取常驻或打开历史。应用行先关闭菜单再导航;底部“管理应用”关闭菜单、恢复锚点焦点并打开既有 `ApplicationCenter` Modal。设备共享始终启用,进入主内容区页面,无常驻字段;业务边界见[设备共享技术设计](../device-sharing/technical-design.md#当前首期实现)。
|
||||
`ApplicationMenu` 按五个应用共用的持久化顺序派生清单,仅按启用过滤,不读取常驻或打开历史。应用行先关闭菜单再导航;底部“管理应用”关闭菜单、恢复锚点焦点并打开既有 `ApplicationCenter` Modal。设备共享使用默认 `false` 的 `deviceSharingEnabled`,启用后进入主内容区页面,无常驻字段;迁移与关闭规则见[设备共享技术设计](../device-sharing/technical-design.md#当前首期实现)。
|
||||
应用中心对应行的设置操作仅接受 `EditableApplicationId`;应用页和全局设置不提供重复入口。知识库的管理行提供“打开”和排序,原工作区实际配置保留,不添加通用空设置页。
|
||||
共同页复用设置 Modal 容器、配置读取及更新方法,不建立独立配置存储或重复表单。
|
||||
|
||||
|
||||
@@ -92,8 +92,9 @@
|
||||
|
||||
## 4. 应用中心与共同设置
|
||||
|
||||
设备共享追加到默认顺序末尾。管理行显示“内置”、打开与排序按钮,不提供通用启用或常驻
|
||||
开关;应用菜单始终保留该项,点击后在主内容区打开[设备共享页面](../device-sharing/ui-design.md#当前首期页面)。
|
||||
设备共享追加到默认顺序末尾,测试阶段默认关闭。管理行显示“可选”及启用状态,提供打开、
|
||||
设置与排序按钮;设置内提供启用 Switch,不提供常驻开关。关闭时禁用“打开”,应用菜单只在
|
||||
启用时显示该项,点击后在主内容区打开[设备共享页面](../device-sharing/ui-design.md#当前首期页面)。
|
||||
|
||||
底部点击打开轻量菜单,锚定按钮向上展开,无模态遮罩、背景隔离或焦点循环。菜单按共享保存顺序列出已启用应用,不按打开历史或常驻状态过滤。点击应用行关闭菜单,本机推理监控打开独立 Modal,知识库、智能心跳和魔法笔记在主内容区打开页面。分隔线下的“管理应用”关闭菜单并打开既有管理 Modal。
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
## 3. Epic B:管理应用入口
|
||||
|
||||
本节的启用、常驻和共同设置适用于智能心跳、魔法笔记、本机推理监控;五个应用均可排序,知识库始终启用并常驻。设备共享始终可从应用菜单和管理 Modal 打开,不提供启用或常驻开关;业务验收见[设备共享首期需求](../device-sharing/prd.md#当前首期本地元数据)。心跳的默认关闭及执行边界按[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)验收,保留既有故事 ID。
|
||||
本节的启用、常驻和共同设置适用于智能心跳、魔法笔记、本机推理监控;五个应用均可排序,知识库始终启用并常驻。设备共享在测试阶段默认关闭,提供启用开关,不提供常驻开关;启用后可从应用菜单和管理 Modal 打开,关闭后退出当前页面并保留目录及发布记录,旧设置缺失时默认关闭,明确启用值在重启后保留。业务验收见[设备共享首期需求](../device-sharing/prd.md#当前首期本地元数据)。心跳的默认关闭及执行边界按[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)验收,保留既有故事 ID。
|
||||
|
||||
### US-B1 识别完整应用
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
验收:
|
||||
|
||||
- Given 首次使用且未配置导航偏好
|
||||
- Then 默认顺序为知识库、智能心跳、魔法笔记、本机推理监控、设备共享;心跳默认关闭且保留默认常驻偏好,魔法笔记默认常驻,本机推理监控默认启用但不常驻,仍可从应用菜单打开;设备共享只在应用菜单和管理 Modal 提供入口;侧栏只按启用与常驻过滤可选项
|
||||
- Then 默认顺序为知识库、智能心跳、魔法笔记、本机推理监控、设备共享;心跳默认关闭且保留默认常驻偏好,魔法笔记默认常驻,本机推理监控默认启用但不常驻,仍可从应用菜单打开;设备共享默认关闭,启用后只在应用菜单和管理 Modal 提供入口;侧栏只按启用与常驻过滤可选项
|
||||
- When 用户修改常驻,或通过拖动、键盘可操作的上移和下移按钮排序并保存成功
|
||||
- Then 管理列表从上到下、菜单和侧栏按同一保存顺序显示,知识库和智能心跳可移动;当前对话、运行记录及底部系统入口位置不变,重启后恢复保存结果
|
||||
- And 每行均提供上下移动,以完整保存顺序中的相邻应用为目标;未常驻及关闭应用仍可排序,设置详情不提供排序控件
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# 工作栏实现与验证进度
|
||||
|
||||
## 2026-10-01:自定义 MCP 失败中断回复
|
||||
|
||||
已修复共用网关将单个上游发现失败升级为整轮失败的问题,行为见
|
||||
[本地 MCP 初始化失败恢复](./runtime-process-reuse-technical-design.md#41-本地-mcp-初始化失败恢复)。
|
||||
正常服务的连接继续复用,失败服务不进入工具列表,动态刷新失败也按服务隔离;
|
||||
取消和授权撤销继续生效。初次失败的服务在下一请求重试,原有连接超时未改。
|
||||
|
||||
验证使用真实 HTTP 上游和网关,覆盖部分失败、无可用工具、非法分页和超量目录、
|
||||
动态刷新失败后恢复、取消及撤销。Continue 使用真实网关和模拟 Host;新增实际 OpenCode
|
||||
二进制用例在自定义 MCP 返回 HTTP 503 后完成 Execute 回复,单次本地合成模型调用,
|
||||
外部模型调用 0 次。网关与 Continue 定向测试 65 项通过;OpenCode 生命周期 4 项通过。
|
||||
`npm run typecheck`、四个修改源码文件的 ESLint 通过。
|
||||
|
||||
完整 `npm test`:5,565 通过、89 跳过、4 失败。失败为 Agent 大依赖清单安装超时、
|
||||
Magic Notes 采集的 `memo is not defined`,以及终态探针和手动压缩的界面等待超时。
|
||||
全仓 lint 在并行修改的 `App.tsx` 性能测量代码处报告渲染纯度及不可变性错误;
|
||||
本次未修改这些文件,不将完整验证记为通过。
|
||||
远程 Agent 的独立 MCP 实现及 Story Graph 绑定不经过改动方法,本次无需远程 Host 验证。
|
||||
尚未发布或替换已安装应用。
|
||||
|
||||
## 2026-10-01:本地 MCP 晚到初始化覆盖新连接
|
||||
|
||||
根因修复见[本地 MCP 初始化失败恢复](./runtime-process-reuse-technical-design.md#41-本地-mcp-初始化失败恢复)。
|
||||
|
||||
@@ -185,6 +185,14 @@ SelectedRuntimeManager
|
||||
错误采用同一判定;HTTP/API 错误、鉴权或协议错误、未知失败不自动重试。
|
||||
取消立即终止后续尝试。Prompt 和工具调用不重放。
|
||||
|
||||
自定义 MCP 上游发现由 `KnowledgeMcpGateway.getCustomMcpBindings` 按服务隔离失败。
|
||||
某个服务连接、列举或校验工具失败时,记录脱敏诊断并跳过该服务,保留正常服务的连接;
|
||||
全部失败时返回空的自定义工具列表,Continue 和内置 OpenCode 仍可开始回复。
|
||||
首次发现的结果在当前授权内复用,初次失败的服务在下一次请求创建授权时重试。
|
||||
动态刷新失败会清空该服务的工具列表,下次发现时再刷新,不阻断其他服务。
|
||||
请求取消和授权撤销仍终止操作,工具总数和名称冲突检查保留。发现仍等待连接尝试结束,
|
||||
因此连接超时可能延迟首个回复;失败隔离不等于后台加载。
|
||||
|
||||
OpenCode 1.18.x 的 `mcp.add` 在服务端独立执行:客户端超时或取消不会中止初始化,
|
||||
`disconnect` 也只清理当时已登记的连接。晚到的初始化完成后会按名称写入状态,并关闭同名
|
||||
的现有连接。因此,`mcp.add` 未得到 HTTP 响应(抛错、超时、取消或无 response 的网络错误),
|
||||
@@ -199,6 +207,7 @@ OpenCode 1.18.x 的 `mcp.add` 在服务端独立执行:客户端超时或取
|
||||
|
||||
此恢复逻辑属于本地 `OpenCodeRuntime`。远程 Agent 使用 `RuntimeAcpBackend` 和
|
||||
`AgentImageToolMcp`,不经过本地 MCP 注册或网关 HTTP 处理;本节不代表远程恢复能力。
|
||||
远程 Story Graph 通过网关的独立绑定调用,不经过上述自定义 MCP 发现方法。
|
||||
|
||||
## 5. DeepSeek Harness
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# 设备发现与共享
|
||||
|
||||
设备共享从“应用 → 设备共享”或“管理应用 → 设备共享 → 打开”进入。当前首期连接本地未认证
|
||||
设备共享在测试阶段默认关闭,先在“管理应用 → 设备共享 → 应用设置”启用,再从“应用 →
|
||||
设备共享”或管理列表的“打开”进入。当前首期连接本地未认证
|
||||
ShareServer 元数据服务,默认地址为 `http://127.0.0.1:8787`,可注册本机、浏览设备与发布目录、
|
||||
发布能力或知识的元数据并撤销本机 ID 的条目。设备 ID 只用于识别,不是所有权认证。
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
本节对应 [FR-M1 至 FR-M5](./prd.md#当前首期本地元数据)。后文发现、配对、授权和任务状态均为
|
||||
后续设计,不适用于本地未认证元数据服务。
|
||||
|
||||
应用启用状态默认关闭,缺失设置按关闭处理,明确保存的启用状态在重启后保留。启用只开放
|
||||
页面入口;打开页面才读取目录。关闭后菜单隐藏该项,当前页面返回对话;不清除设备设置、
|
||||
目录或已发布记录,不停止已有请求。该应用开关不代替后续发现、广播和发布的独立状态。
|
||||
|
||||
本机 ID 在首次读取设置时生成并原子保存;并发首次读取共用一次加载。名称与服务 URL 更新
|
||||
串行保存,不接受 Renderer 写入设备 ID。已存在设置无法读取或解析时直接报错,避免静默
|
||||
更换身份。切换服务保持本机 ID,但目标服务必须有本机注册记录才能发布。
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
2026-10-01 首期范围由 `FR-M1` 至 `FR-M5` 定义。后文 `FR-1` 至 `FR-14` 保留为后续共享网络
|
||||
设计,不能用来推断首期已有发现、认证、调用或内容传输能力。
|
||||
|
||||
- **FR-M1 入口与设置**:应用中心及应用菜单提供“设备共享”。本机设备 ID 稳定保存,用户可
|
||||
- **FR-M1 入口与设置**:设备共享为可选应用,测试阶段默认关闭,应用中心提供启用开关;
|
||||
启用后才出现在应用菜单,关闭时退出当前设备共享页面。旧设置缺少开关时按关闭处理,已保存的
|
||||
明确启用值保留。关闭不清除目录、设备设置或撤销已发布记录。本机设备 ID 稳定保存,用户可
|
||||
修改服务 URL 和设备名称。设置保存后重启仍保留;新设备默认连接 `http://127.0.0.1:8787`。
|
||||
- **FR-M2 注册与浏览**:用户显式注册本机,随后查看服务实际返回的设备及发布列表。注册
|
||||
包含 ID、名称、平台、应用版本;时间来自服务记录,不从更新时间推断在线状态。
|
||||
@@ -28,7 +30,7 @@
|
||||
- **FR-M5 失败恢复**:请求有超时,错误可见;失败保留用户输入,列表失败不伪装为空目录。
|
||||
修改服务 URL 后必须先保存,再向新服务注册或发布。既有应用设置、排序和常驻偏好保留。
|
||||
|
||||
验收顺序为保存配置、注册本机、浏览返回列表、发布知识元数据、检查三个独立标志、撤销、
|
||||
验收顺序为启用应用、保存配置、注册本机、浏览返回列表、发布知识元数据、检查三个独立标志、撤销、
|
||||
刷新并重启读取设备 ID。网络请求由 Main 发出,Renderer 只调用显式 preload 方法。
|
||||
|
||||
## 后续设计
|
||||
|
||||
@@ -2,6 +2,27 @@
|
||||
|
||||
日期:2026-10-01。当前实现范围见 [README](./README.md)。
|
||||
|
||||
## 2026-10-01:测试阶段默认关闭
|
||||
|
||||
对应 FR-M1,新增持久化 `deviceSharingEnabled`,默认 `false`,旧设置缺少字段时补为关闭,
|
||||
明确保存的 `true` 保留。应用中心提供启用 Switch,不增加常驻;菜单仅显示已启用项。
|
||||
从应用中心或设置变更事件关闭时,App 退出设备共享页面并返回对话;设备设置、目录与已发布
|
||||
记录保留。中英文文案、功能列表和导航文档已同步。
|
||||
|
||||
本轮验证:设置存储、配置工具契约及服务、应用中心、菜单和共享页面共 6 文件 112 项通过;
|
||||
完整 `App.test.tsx` 321 项通过;设置面板、更新设置及笔记工作区 3 文件 246 项通过。
|
||||
`npm run typecheck` 与 `npm run lint` 通过。首次 App 回归发现关闭逻辑误触其他设置导航,
|
||||
限定为当前设备共享页后复跑通过;新增测试的查询参数类型错误也已修正。
|
||||
|
||||
设置存储回归覆盖版本 11/12 的字段缺失、显式开启/关闭、无关更新、事件与重读;App 回归
|
||||
覆盖默认隐藏、启用、菜单打开、UI 关闭和外部事件关闭,重新启用不自动返回该页。
|
||||
真实 Electron/ShareServer 用例 1 项通过,新增默认禁用“打开”、仅一个启用 Switch 的检查,
|
||||
启用后继续注册、发布、撤销、刷新及浅深主题 480px 检查。该 Electron 用例的应用设置由组件
|
||||
fixture 更新,持久化由真实设置存储测试覆盖,不据此声明完整生产 App 或安装包联调通过。
|
||||
本轮未运行全仓测试,未改动 Agent/Runtime 路径,无模型调用。
|
||||
|
||||
## 首期接入记录
|
||||
|
||||
`a3b41bf` 已接入应用入口、Main 服务、可信 IPC、preload 与页面,
|
||||
支持配置 ShareServer、注册设备、读取目录、发布/撤销元数据及本机 ID 持久化。
|
||||
设备 ID 不是认证凭据,权限字段不代表已实现远程操作;正文传输、能力调用、
|
||||
@@ -66,7 +87,8 @@ npm test
|
||||
|
||||
### 手工体验
|
||||
|
||||
在 ShareServer 仓库运行 `npm run dev:api`,在 GoodBuddy 仓库运行 `npm run dev`。从“应用”
|
||||
打开“设备共享”,默认地址为 `http://127.0.0.1:8787`。先保存名称或地址修改,再注册本机、
|
||||
在 ShareServer 仓库运行 `npm run dev:api`,在 GoodBuddy 仓库运行 `npm run dev`。先从“应用 →
|
||||
管理应用 → 设备共享 → 应用设置”启用,再从应用菜单打开“设备共享”,默认地址为
|
||||
`http://127.0.0.1:8787`。先保存名称或地址修改,再注册本机、
|
||||
发布条目、检查目录、撤销并刷新。知识类型提供三个独立权限框;重启后可比较同一设备 ID。
|
||||
服务关闭时刷新应显示错误并保留旧列表,不应显示为空目录或推断设备离线。
|
||||
|
||||
@@ -36,7 +36,10 @@ URL 接受 HTTP/HTTPS,禁止内嵌凭据、查询和片段。没有令牌或
|
||||
设备 ID、名称与服务 URL 存在用户数据目录的 `device-sharing-settings.json`,版本为 1;
|
||||
复用 `writeJsonFileAtomically`,加载 Promise 去重、保存串行。损坏设置报错而不重置 ID。
|
||||
此文件不保存目录副本或凭据。导航沿用已有存储归一化,把 `device-sharing` 追加到旧顺序,
|
||||
保留既有应用相对顺序与设置;新入口可排序,无启用或常驻开关,始终可从应用菜单进入。
|
||||
保留既有应用相对顺序与设置;新入口可排序,不增加常驻字段。应用设置中的
|
||||
`deviceSharingEnabled` 使用共享 schema 的 `false` 默认值补齐缺失字段,显式 `true` 保留;
|
||||
沿用现有版本与原子持久化、变更事件。更新 schema 将该字段设为可选,避免无关更新重置选择。
|
||||
菜单复用启用过滤,App 收到关闭设置时返回对话并卸载页面。共享服务和目录数据不受此开关修改。
|
||||
|
||||
服务测试使用真实 loopback HTTP,验证契约、独立权限、识别范围、持久化、错误和超时;
|
||||
Electron 测试通过环境变量选择相邻真实 ShareServer 源码,使用临时服务数据与桌面设置。
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
|
||||
本节对应 [FR-M1 至 FR-M5](./prd.md#当前首期本地元数据),后文四页签、配对及调用界面为后续设计。
|
||||
|
||||
入口在应用菜单和应用中心,主内容区使用 `standard` PageShell、PageHeader 与全局范围徽标。
|
||||
应用中心显示“可选”及启用状态,测试阶段默认关闭,此时“打开”禁用。应用设置复用共享
|
||||
Switch,常显测试状态和关闭后果,不提供常驻开关。启用后应用菜单显示入口,关闭时退出
|
||||
设备共享页面并返回对话。主内容区使用 `standard` PageShell、PageHeader 与全局范围徽标。
|
||||
标题下常显未认证、ID 识别用途和无执行/内容访问的边界。内容依次为本机设置、已注册设备、
|
||||
发布表单和发布目录。服务 URL 与设备名称可编辑,设备 ID 只读;注册、保存与刷新为次操作,
|
||||
“发布元数据”为唯一主按钮。
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
| 用户任务 | 主入口与路由 | 范围及目标行为 |
|
||||
| --- | --- | --- |
|
||||
| 配置解析与 OCR | 侧栏底部“设置” → “文档解析”(`document-parsing`) | 设备级共享配置,分别保留聊天与知识库工作流;HTTP 地址、凭据和高级选项只在此维护,不随当前项目或会话切换 |
|
||||
| 管理本地 OCR 文件 | 同一“文档解析”分类的本地模型区 | 下载、校验、ZIP、删除和目录操作;全局模型下载源仍在“平台功能 / 通用设置”,与 OCR 来源选择分开 |
|
||||
| 管理本地 OCR 文件 | 同一“文档解析”分类的本地模型区 | 下载、校验、ZIP、删除和目录操作;更换下载源入口直接打开“模型连接”页头的模型下载源弹窗,与 OCR 来源选择分开;弹窗交互见[模型下载源设计](../../architecture/model-download-source-design.md#7-模型下载源界面) |
|
||||
| 查看或释放本地 OCR 内存 | “应用中心” → “本机推理监控”(`local-inference` Modal),或其常驻入口 | 只管理本机执行服务;应用启用及常驻控制入口,不控制解析功能;HTTP 服务不出现在本机服务列表,也不提供远程启停 |
|
||||
| 添加文件或图片 | “当前对话” → “添加附件”,或输入框粘贴 | 先加入当前草稿,再按输入类型处理;上传按钮继续直接选文件,不改成先选 OCR 的菜单 |
|
||||
| 查看聊天解析结果 | 草稿或历史消息的附件卡 | 打开共享预览;来源消息与目标草稿分别显示,历史输入只读 |
|
||||
|
||||
@@ -911,8 +911,8 @@ Endpoint、编码配置或服务端稳定模型摘要变化。
|
||||
|
||||
- 目录只包含元数据,不包含模型权重。
|
||||
- 每个来源的文件固定不可变 Revision、URL、字节数和 SHA-256。
|
||||
- 实际下载使用“平台功能 → 通用设置”中显式选择的 ModelScope 或 Hugging Face,完整
|
||||
来源契约以[平台功能页签与模型下载源设计](../../architecture/model-download-source-design.md)为准。
|
||||
- 实际下载使用“模型连接”页头的模型下载源弹窗中确认保存的 ModelScope 或 Hugging Face;
|
||||
向量模型的下载源入口直接打开该弹窗,完整来源契约以[平台功能页签与模型下载源设计](../../architecture/model-download-source-design.md)为准。
|
||||
- 下载写入随机暂存目录和 `.partial` 文件。
|
||||
- 禁止未声明重定向到其他主机。
|
||||
- 每个文件边下载边计算摘要。
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import type { KnowledgeService } from '../knowledge/knowledge-service'
|
||||
import {
|
||||
ContinueHostRunError,
|
||||
type ContinueHostAdapterOptions
|
||||
} from './continue-host-adapter'
|
||||
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
detectRuntimeBinary: vi.fn(),
|
||||
@@ -464,6 +466,70 @@ describe('ContinueAgentRuntime', () => {
|
||||
expect(gateway.grantCustomMcp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('completes the Continue run when an assigned custom MCP server returns HTTP 503', async () => {
|
||||
const requests = vi.fn((_request: IncomingMessage, response: ServerResponse) => {
|
||||
response.writeHead(503, { Connection: 'close' })
|
||||
response.end('Service unavailable')
|
||||
})
|
||||
const upstream = createServer(requests)
|
||||
const gateway = new KnowledgeMcpGateway({} as KnowledgeService)
|
||||
let runtime: ContinueAgentRuntime | undefined
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upstream.once('error', reject)
|
||||
upstream.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = upstream.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Custom MCP test server did not bind a TCP port')
|
||||
}
|
||||
await gateway.start()
|
||||
runtime = new ContinueAgentRuntime({
|
||||
binaryPath: '',
|
||||
configPath: 'C:\\safe config\\continue.yaml',
|
||||
defaultWorkspace: process.cwd(),
|
||||
hostCacheRoot: 'C:\\safe\\continue-host',
|
||||
knowledgeGateway: gateway,
|
||||
mcpServers: [{
|
||||
id: randomUUID(),
|
||||
name: 'Unavailable MCP',
|
||||
description: '',
|
||||
enabled: true,
|
||||
allowDynamicTools: false,
|
||||
assignments: ['continue'],
|
||||
secretConfigured: false,
|
||||
transport: 'http',
|
||||
url: `http://127.0.0.1:${address.port}/mcp`
|
||||
}],
|
||||
createHostAdapter: () => ({
|
||||
getPreparedHost: mocks.prepareHost,
|
||||
run: mocks.runHost,
|
||||
dispose: mocks.disposeHost
|
||||
})
|
||||
})
|
||||
|
||||
const events = await collectEvents(runtime, 'execute')
|
||||
|
||||
expect(requests).toHaveBeenCalled()
|
||||
expect(mocks.runHost).toHaveBeenCalledOnce()
|
||||
expect(events).toContainEqual({
|
||||
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
||||
type: 'text',
|
||||
delta: 'Continue response'
|
||||
})
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
} finally {
|
||||
await Promise.all([
|
||||
runtime?.dispose(),
|
||||
gateway.dispose(),
|
||||
new Promise<void>((resolve) => {
|
||||
upstream.close(() => resolve())
|
||||
upstream.closeAllConnections()
|
||||
})
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it.each([undefined, 'existing-capability'])('cleans up image capabilities after custom MCP preparation fails (existing=%s)', async existingToken => {
|
||||
const gateway = {
|
||||
getEndpoint: () => 'http://127.0.0.1:4567/mcp',
|
||||
|
||||
@@ -1333,7 +1333,7 @@ describe('KnowledgeMcpGateway', () => {
|
||||
expect(outputValidator).toHaveBeenCalledWith({ value: 'done' })
|
||||
})
|
||||
|
||||
it('rejects cyclic cursors and custom MCP tool counts over 100', async () => {
|
||||
it('omits servers with cyclic cursors or custom MCP tool counts over 100', async () => {
|
||||
const cyclicUpstream = await startToolUpstream(
|
||||
(cursor: string | undefined) => ({
|
||||
tools: [testTool(cursor ? 'second' : 'first')],
|
||||
@@ -1366,18 +1366,63 @@ describe('KnowledgeMcpGateway', () => {
|
||||
|
||||
await expect(
|
||||
gateway.prepareCustomMcpTools(cycleToken)
|
||||
).rejects.toMatchObject({
|
||||
cause: expect.objectContaining({
|
||||
message: expect.stringContaining('分页游标发生循环')
|
||||
})
|
||||
})
|
||||
).resolves.toEqual([])
|
||||
await expect(
|
||||
gateway.prepareCustomMcpTools(excessiveToken)
|
||||
).rejects.toMatchObject({
|
||||
cause: expect.objectContaining({
|
||||
message: expect.stringContaining('工具数量超过安全限制')
|
||||
})
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('keeps healthy tools available when another upstream fails discovery', async () => {
|
||||
const healthyList = vi.fn(() => ({ tools: [testTool('healthy')] }))
|
||||
const healthy = await startToolUpstream(healthyList)
|
||||
const failedList = vi.fn(() => { throw new Error('private-upstream-failure') })
|
||||
const failed = await startToolUpstream(failedList)
|
||||
const observeFailure = vi.fn()
|
||||
const gateway = new KnowledgeMcpGateway(createService().service, { observeFailure })
|
||||
gateways.push(gateway)
|
||||
await gateway.start()
|
||||
const controller = new AbortController()
|
||||
const token = gateway.grantCustomMcp('partial-discovery', [
|
||||
customMcpServer(healthy.url),
|
||||
customMcpServer(failed.url, '00000000-0000-4000-8000-000000000093')
|
||||
], controller.signal)!
|
||||
await expect(gateway.prepareCustomMcpTools(token)).resolves.toEqual([
|
||||
expect.objectContaining({ name: expect.stringMatching(/_healthy$/u) })
|
||||
])
|
||||
const client = new Client({ name: 'partial-discovery', version: '1.0.0' })
|
||||
try {
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), {
|
||||
requestInit: { headers: { authorization: `Bearer ${token}` } }
|
||||
}))
|
||||
const listed = await client.listTools()
|
||||
expect(listed.tools.map(tool => tool.name)).toEqual([
|
||||
expect.stringMatching(/_healthy$/u)
|
||||
])
|
||||
expect(healthyList).toHaveBeenCalledTimes(1)
|
||||
expect(failedList).toHaveBeenCalledTimes(1)
|
||||
expect(observeFailure).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mcp: expect.objectContaining({ phase: 'tool-discovery' })
|
||||
}))
|
||||
expect(JSON.stringify(observeFailure.mock.calls)).not.toContain('private-upstream-failure')
|
||||
} finally {
|
||||
await client.close()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['abort', 'revoke'] as const)('does not swallow %s during discovery', async action => {
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('request cancelled')
|
||||
const gateway = new KnowledgeMcpGateway(createService().service)
|
||||
gateways.push(gateway)
|
||||
const upstream = await startToolUpstream(() => {
|
||||
if (action === 'abort') controller.abort(reason)
|
||||
else gateway.revoke(token)
|
||||
return { tools: [testTool('late')] }
|
||||
})
|
||||
const token = gateway.grantCustomMcp('cancel-discovery', [customMcpServer(upstream.url)], controller.signal)!
|
||||
const preparation = gateway.prepareCustomMcpTools(token)
|
||||
if (action === 'abort') await expect(preparation).rejects.toBe(reason)
|
||||
else await expect(preparation).rejects.toThrow('MCP capability was revoked')
|
||||
})
|
||||
|
||||
it('releases rejected initialize attempts before enforcing the session limit', async () => {
|
||||
@@ -1542,6 +1587,12 @@ describe('KnowledgeMcpGateway', () => {
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
expect(listChanged).not.toHaveBeenCalled()
|
||||
await expect(client.listTools()).resolves.toMatchObject({ tools: [] })
|
||||
failRefresh = false
|
||||
const recovered = await client.listTools()
|
||||
expect(recovered.tools.map(tool => tool.name)).toEqual([
|
||||
expect.stringMatching(/_stable$/u)
|
||||
])
|
||||
} finally {
|
||||
await client.close()
|
||||
}
|
||||
|
||||
@@ -960,6 +960,7 @@ export class KnowledgeMcpGateway {
|
||||
capability.brokerController.signal
|
||||
])
|
||||
const changeVersion = connection.dynamicToolsChangeVersion
|
||||
const startedAt = performance.now()
|
||||
let refreshSucceeded = false
|
||||
const refresh = (async () => {
|
||||
try {
|
||||
@@ -979,10 +980,15 @@ export class KnowledgeMcpGateway {
|
||||
refreshSucceeded = true
|
||||
await this.publishCustomMcpToolListChanged(capability)
|
||||
} catch (error) {
|
||||
connection.bindings = []
|
||||
connection.dynamicToolsChanged = true
|
||||
if (effectiveSignal.aborted) {
|
||||
throw effectiveSignal.reason
|
||||
}
|
||||
this.observeMcpFailure({
|
||||
phase: 'tool-discovery', category: 'handler-failure', kind: 'custom',
|
||||
elapsedMs: Math.round(performance.now() - startedAt)
|
||||
})
|
||||
throw new Error(
|
||||
`无法刷新 MCP Server「${connection.server.name}」的工具`,
|
||||
{ cause: error }
|
||||
@@ -1008,6 +1014,7 @@ export class KnowledgeMcpGateway {
|
||||
capability: Capability,
|
||||
server: ResolvedMcpServer
|
||||
): Promise<CustomMcpConnection> {
|
||||
const startedAt = performance.now()
|
||||
let connection: CustomMcpConnection | undefined
|
||||
let dynamicToolsChangeVersion = 0
|
||||
const client = new Client(
|
||||
@@ -1079,6 +1086,11 @@ export class KnowledgeMcpGateway {
|
||||
return connection
|
||||
} catch (error) {
|
||||
await client.close().catch(() => undefined)
|
||||
signal.throwIfAborted()
|
||||
this.observeMcpFailure({
|
||||
phase: 'tool-discovery', category: 'handler-failure', kind: 'custom',
|
||||
elapsedMs: Math.round(performance.now() - startedAt)
|
||||
})
|
||||
throw new Error(
|
||||
`无法加载 MCP Server「${server.name}」的工具`,
|
||||
{ cause: error }
|
||||
@@ -1092,6 +1104,12 @@ export class KnowledgeMcpGateway {
|
||||
refreshDynamic = true
|
||||
): Promise<Map<string, CustomMcpBinding>> {
|
||||
const capability = this.getCapability(token)
|
||||
const effectiveSignal = AbortSignal.any([
|
||||
capability.signal,
|
||||
capability.brokerController.signal,
|
||||
...(signal ? [signal] : [])
|
||||
])
|
||||
effectiveSignal.throwIfAborted()
|
||||
if (capability.customMcpServers.length === 0) {
|
||||
return new Map()
|
||||
}
|
||||
@@ -1105,15 +1123,6 @@ export class KnowledgeMcpGateway {
|
||||
const connections = results.flatMap((result) =>
|
||||
result.status === 'fulfilled' ? [result.value] : []
|
||||
)
|
||||
const failure = results.find(
|
||||
(result) => result.status === 'rejected'
|
||||
)
|
||||
if (failure?.status === 'rejected') {
|
||||
await Promise.allSettled(
|
||||
connections.map((connection) => connection.client.close())
|
||||
)
|
||||
throw failure.reason
|
||||
}
|
||||
return connections
|
||||
})()
|
||||
}
|
||||
@@ -1124,6 +1133,7 @@ export class KnowledgeMcpGateway {
|
||||
capability.customMcpConnections = undefined
|
||||
throw error
|
||||
}
|
||||
effectiveSignal.throwIfAborted()
|
||||
if (refreshDynamic) {
|
||||
for (const connection of connections) {
|
||||
if (
|
||||
@@ -1133,13 +1143,19 @@ export class KnowledgeMcpGateway {
|
||||
) {
|
||||
continue
|
||||
}
|
||||
await this.refreshDynamicTools(
|
||||
capability,
|
||||
connection,
|
||||
signal
|
||||
)
|
||||
try {
|
||||
await this.refreshDynamicTools(
|
||||
capability,
|
||||
connection,
|
||||
signal
|
||||
)
|
||||
} catch {
|
||||
// Failed catalogs are cleared by refresh; healthy servers remain usable.
|
||||
effectiveSignal.throwIfAborted()
|
||||
}
|
||||
}
|
||||
}
|
||||
effectiveSignal.throwIfAborted()
|
||||
const bindings = new Map<string, CustomMcpBinding>()
|
||||
for (const connection of connections) {
|
||||
for (const binding of connection.bindings) {
|
||||
|
||||
@@ -16,6 +16,95 @@ const binaryPath = join(
|
||||
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
|
||||
)
|
||||
|
||||
it.skipIf(!existsSync(binaryPath))(
|
||||
'completes Execute with the real OpenCode binary when an assigned custom MCP returns HTTP 503',
|
||||
async () => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-mcp-unavailable-'))
|
||||
let modelCalls = 0
|
||||
const model = createServer((_request, response) => {
|
||||
modelCalls++
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.end(`data: ${JSON.stringify({
|
||||
id: 'unavailable', object: 'chat.completion.chunk', created: 1, model: 'unavailable',
|
||||
choices: [{ index: 0, delta: { role: 'assistant', content: 'MCP_UNAVAILABLE_OK' }, finish_reason: 'stop' }]
|
||||
})}\n\ndata: [DONE]\n\n`)
|
||||
})
|
||||
let mcpRequests = 0
|
||||
const failureBody = 'private-mcp-failure-detail'
|
||||
const upstream = createServer((_request, response) => {
|
||||
mcpRequests++
|
||||
response.writeHead(503, { Connection: 'close' })
|
||||
response.end(failureBody)
|
||||
})
|
||||
const records: DesktopDiagnosticFailure[] = []
|
||||
const gateway = new KnowledgeMcpGateway({} as never, {
|
||||
observeFailure: failure => records.push(failure)
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const deadline = setTimeout(() => controller.abort(), 30_000)
|
||||
let runtime: OpenCodeRuntime | undefined
|
||||
try {
|
||||
await new Promise<void>(resolve => model.listen(0, '127.0.0.1', resolve))
|
||||
await new Promise<void>(resolve => upstream.listen(0, '127.0.0.1', resolve))
|
||||
const modelAddress = model.address()
|
||||
const mcpAddress = upstream.address()
|
||||
if (!modelAddress || typeof modelAddress === 'string') throw new Error('No model port')
|
||||
if (!mcpAddress || typeof mcpAddress === 'string') throw new Error('No MCP port')
|
||||
await gateway.start()
|
||||
const secret = 'private-mcp-fixture-secret'
|
||||
const url = `http://127.0.0.1:${mcpAddress.port}/mcp`
|
||||
runtime = new OpenCodeRuntime({
|
||||
embedded: true, binaryPath: '', bundledBinaryPath: binaryPath,
|
||||
configPath: '', defaultWorkspace: workspace, knowledgeGateway: gateway,
|
||||
mcpServers: [{
|
||||
id: crypto.randomUUID(), name: 'Unavailable MCP', description: '',
|
||||
enabled: true, allowDynamicTools: false, assignments: ['opencode'],
|
||||
secretConfigured: true, secret, transport: 'http', url
|
||||
}],
|
||||
modelProfile: {
|
||||
id: crypto.randomUUID(), name: 'Unavailable MCP fixture', modelName: 'unavailable',
|
||||
baseUrl: `http://127.0.0.1:${modelAddress.port}/v1`,
|
||||
protocol: 'openai-chat-completions', authentication: 'none'
|
||||
}
|
||||
})
|
||||
let text = ''
|
||||
let lastEventType: string | undefined
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), workMode: 'execute',
|
||||
prompt: 'Reply MCP_UNAVAILABLE_OK without tools.'
|
||||
}, controller.signal)) {
|
||||
if (event.type === 'text') text += event.delta
|
||||
lastEventType = event.type
|
||||
}
|
||||
expect(mcpRequests).toBeGreaterThan(0)
|
||||
expect(text).toBe('MCP_UNAVAILABLE_OK')
|
||||
expect(lastEventType).toBe('done')
|
||||
expect(modelCalls).toBe(1)
|
||||
expect(records).toHaveLength(1)
|
||||
expect(records[0]).toMatchObject({
|
||||
code: 'runtime.mcp.failed',
|
||||
mcp: { phase: 'tool-discovery', category: 'handler-failure' }
|
||||
})
|
||||
const diagnostics = JSON.stringify(records, (_key, value) => value instanceof Error
|
||||
? { message: value.message, stack: value.stack, cause: value.cause }
|
||||
: value)
|
||||
expect(diagnostics).not.toContain(secret)
|
||||
expect(diagnostics).not.toContain(url)
|
||||
expect(diagnostics).not.toContain(failureBody)
|
||||
} finally {
|
||||
clearTimeout(deadline)
|
||||
await runtime?.dispose()
|
||||
await gateway.dispose()
|
||||
upstream.closeAllConnections()
|
||||
model.closeAllConnections()
|
||||
await new Promise<void>(resolve => upstream.close(() => resolve()))
|
||||
await new Promise<void>(resolve => model.close(() => resolve()))
|
||||
await rm(workspace, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
60_000
|
||||
)
|
||||
|
||||
it.skipIf(!existsSync(binaryPath))(
|
||||
'recovers a lost MCP add response with the real OpenCode binary and gateway',
|
||||
async () => {
|
||||
|
||||
@@ -44,6 +44,37 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('ApplicationSettingsStore', () => {
|
||||
it.each([11, 12])('defaults missing device sharing to off and preserves explicit choices in version %s', async (version) => {
|
||||
for (const deviceSharingEnabled of [undefined, false, true]) {
|
||||
const { filePath, store } = await createStore()
|
||||
await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, version,
|
||||
lastSeenReleaseNotesVersion: null, deviceSharingEnabled, checkUpdatesOnStartup: false }))
|
||||
expect(await store.get()).toMatchObject({ deviceSharingEnabled: deviceSharingEnabled ?? false, checkUpdatesOnStartup: false })
|
||||
await store.update({ magicNotesEnabled: false })
|
||||
expect((await createApplicationSettingsStore(filePath).get()).deviceSharingEnabled).toBe(deviceSharingEnabled ?? false)
|
||||
}
|
||||
})
|
||||
|
||||
it('persists and publishes device sharing toggles without resetting them on unrelated updates', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
expect((await store.get()).deviceSharingEnabled).toBe(false)
|
||||
expect(applicationSettingsSchema.parse({ ...defaultApplicationSettings, deviceSharingEnabled: undefined }).deviceSharingEnabled).toBe(false)
|
||||
const changed = vi.fn()
|
||||
store.onChanged(changed)
|
||||
for (const deviceSharingEnabled of [true, false]) {
|
||||
const saved = await store.update({ deviceSharingEnabled })
|
||||
expect(saved.deviceSharingEnabled).toBe(deviceSharingEnabled)
|
||||
expect(changed).toHaveBeenLastCalledWith(saved)
|
||||
await store.update({ checkUpdatesOnStartup: false })
|
||||
expect((await createApplicationSettingsStore(filePath).get()).deviceSharingEnabled).toBe(deviceSharingEnabled)
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8')).deviceSharingEnabled).toBe(deviceSharingEnabled)
|
||||
}
|
||||
expect(applicationSettingsUpdateSchema.parse({ checkUpdatesOnStartup: false })).toEqual({ checkUpdatesOnStartup: false })
|
||||
for (const deviceSharingEnabled of ['false', 0, null]) {
|
||||
await expect(store.update({ deviceSharingEnabled })).rejects.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([11, 12])('defaults missing frosted glass to off in version %s', async (version) => {
|
||||
const { filePath, store } = await createStore()
|
||||
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
|
||||
|
||||
@@ -259,6 +259,7 @@ export const defaultApplicationSettings: ApplicationSettings = {
|
||||
remoteProjectsEnabled: false,
|
||||
applicationNavigation: defaultApplicationNavigation,
|
||||
localInferenceEnabled: true,
|
||||
deviceSharingEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
heartbeatEnabled: false,
|
||||
heartbeatReportTimeoutSeconds: defaultSupervisionTimeoutSeconds,
|
||||
@@ -297,6 +298,7 @@ export class ApplicationSettingsStore {
|
||||
| 'version'
|
||||
| 'applicationNavigation'
|
||||
| 'localInferenceEnabled'
|
||||
| 'deviceSharingEnabled'
|
||||
| 'heartbeatEnabled'
|
||||
| 'magicNoteCanvasPageCount'
|
||||
> & {
|
||||
@@ -537,6 +539,7 @@ export class ApplicationSettingsStore {
|
||||
remoteProjectsEnabled: stored.remoteProjectsEnabled,
|
||||
applicationNavigation: stored.applicationNavigation,
|
||||
localInferenceEnabled: stored.localInferenceEnabled,
|
||||
deviceSharingEnabled: stored.deviceSharingEnabled,
|
||||
magicNotesEnabled: stored.magicNotesEnabled,
|
||||
heartbeatEnabled: stored.heartbeatEnabled ?? false,
|
||||
heartbeatReportTimeoutSeconds: stored.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
|
||||
@@ -584,6 +587,7 @@ export class ApplicationSettingsStore {
|
||||
remoteProjectsEnabled: next.remoteProjectsEnabled,
|
||||
applicationNavigation: next.applicationNavigation,
|
||||
localInferenceEnabled: next.localInferenceEnabled,
|
||||
deviceSharingEnabled: next.deviceSharingEnabled,
|
||||
magicNotesEnabled: next.magicNotesEnabled,
|
||||
heartbeatEnabled: next.heartbeatEnabled ?? false,
|
||||
heartbeatReportTimeoutSeconds: next.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
|
||||
|
||||
@@ -959,23 +959,32 @@ describe('ActivityPanel', () => {
|
||||
expect(screen.getByRole('row', { name: '直连模型 · gpt-sys 16 2 0 0 0% 18' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an empty system tab and refreshes usage on demand', async () => {
|
||||
it('shows an empty system tab and refreshes from the header on demand', async () => {
|
||||
let resolveRefresh!: () => void
|
||||
const onRefreshTokenUsage = vi.fn(() => new Promise<void>((resolve) => { resolveRefresh = resolve }))
|
||||
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} onRefreshTokenUsage={onRefreshTokenUsage}
|
||||
const onRefresh = vi.fn(() => new Promise<void>((resolve) => { resolveRefresh = resolve }))
|
||||
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} onRefresh={onRefresh}
|
||||
records={[]} tokenUsage={makeTokenUsage()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '系统任务' }))
|
||||
expect(screen.getByText('暂无系统任务 Token 用量')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
|
||||
expect(onRefreshTokenUsage).toHaveBeenCalledOnce()
|
||||
const header = screen.getByRole('heading', { level: 1, name: '运行记录' }).closest('header')!
|
||||
fireEvent.click(within(header).getByRole('button', { name: '刷新' }))
|
||||
expect(onRefresh).toHaveBeenCalledOnce()
|
||||
const busy = screen.getByRole('button', { name: '正在刷新' })
|
||||
expect(busy).toBeDisabled()
|
||||
fireEvent.click(busy)
|
||||
expect(onRefreshTokenUsage).toHaveBeenCalledOnce()
|
||||
expect(onRefresh).toHaveBeenCalledOnce()
|
||||
resolveRefresh()
|
||||
expect(await screen.findByRole('button', { name: '刷新' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('offers the header refresh on the task tab too', () => {
|
||||
const onRefresh = vi.fn(async () => {})
|
||||
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} onRefresh={onRefresh}
|
||||
records={[makeRecord(1)]} tokenUsage={makeTokenUsage()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
|
||||
expect(onRefresh).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides the refresh button when no refresh handler is provided', () => {
|
||||
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} records={[]} tokenUsage={makeTokenUsage()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '用量统计' }))
|
||||
|
||||
@@ -45,8 +45,11 @@ export type ActivityPanelProps = {
|
||||
tokenUsage: TokenUsageSummary
|
||||
onClear: () => void
|
||||
onOpenConversation: (conversationId: string) => void
|
||||
/** Re-reads token usage from storage; the button is hidden when omitted. */
|
||||
onRefreshTokenUsage?: () => Promise<void>
|
||||
/**
|
||||
* Reloads the activity snapshot and token usage. The page does not follow
|
||||
* live updates; the header refresh button is hidden when omitted.
|
||||
*/
|
||||
onRefresh?: () => Promise<void>
|
||||
}
|
||||
|
||||
type ConversationActivityGroup = {
|
||||
@@ -251,7 +254,7 @@ export const ActivityPanel = memo(function ActivityPanel({
|
||||
tokenUsage,
|
||||
onClear,
|
||||
onOpenConversation,
|
||||
onRefreshTokenUsage
|
||||
onRefresh
|
||||
}: ActivityPanelProps): React.JSX.Element {
|
||||
const { t, i18n } = useTranslation('activity')
|
||||
const { t: tWorkspace } = useTranslation('workspace')
|
||||
@@ -262,7 +265,7 @@ export const ActivityPanel = memo(function ActivityPanel({
|
||||
useState<Exclude<TokenUsageGroup, 'source'>>('project')
|
||||
const [systemTokenGroup, setSystemTokenGroup] =
|
||||
useState<SystemTokenUsageGroup>('source')
|
||||
const [refreshingTokenUsage, setRefreshingTokenUsage] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [expandedTokenRows, setExpandedTokenRows] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
@@ -612,15 +615,15 @@ export const ActivityPanel = memo(function ActivityPanel({
|
||||
: row.label
|
||||
return { label }
|
||||
}
|
||||
const refreshTokenUsage = async (): Promise<void> => {
|
||||
if (!onRefreshTokenUsage || refreshingTokenUsage) {
|
||||
const refresh = async (): Promise<void> => {
|
||||
if (!onRefresh || refreshing) {
|
||||
return
|
||||
}
|
||||
setRefreshingTokenUsage(true)
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await onRefreshTokenUsage()
|
||||
await onRefresh()
|
||||
} finally {
|
||||
setRefreshingTokenUsage(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
const renderRecordCard = (
|
||||
@@ -760,6 +763,21 @@ export const ActivityPanel = memo(function ActivityPanel({
|
||||
className="activity-panel"
|
||||
>
|
||||
<PageHeader
|
||||
actions={
|
||||
onRefresh && (
|
||||
<button
|
||||
aria-busy={refreshing}
|
||||
className="secondary-button activity-panel__refresh"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refresh()}
|
||||
title={t('refresh.hint')}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={13} />
|
||||
{refreshing ? t('refresh.refreshing') : t('refresh.label')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
help={t('header.description')}
|
||||
headingId="activity-panel-title"
|
||||
icon={<Activity size={20} />}
|
||||
@@ -1125,21 +1143,6 @@ export const ActivityPanel = memo(function ActivityPanel({
|
||||
options={groups}
|
||||
value={group}
|
||||
/>
|
||||
{onRefreshTokenUsage && (
|
||||
<button
|
||||
aria-busy={refreshingTokenUsage}
|
||||
className="secondary-button token-usage__refresh"
|
||||
disabled={refreshingTokenUsage}
|
||||
onClick={() => void refreshTokenUsage()}
|
||||
title={t('tokenUsage.refreshHint')}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={13} />
|
||||
{refreshingTokenUsage
|
||||
? t('tokenUsage.refreshing')
|
||||
: t('tokenUsage.refresh')}
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
{description && (
|
||||
<p className="token-usage__description">{description}</p>
|
||||
|
||||
+193
-26
@@ -79,6 +79,15 @@ vi.mock('./ConversationTaskStrip', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('./ConversationTaskStrip')>();
|
||||
return { ...original, ConversationTaskStrip: historyRenderProbes.instrument(original.ConversationTaskStrip, historyRenderProbes.task) };
|
||||
});
|
||||
const sidebarRenderProbes = vi.hoisted(() => ({ row: vi.fn(), projectActivity: vi.fn() }));
|
||||
vi.mock('./OverflowMarquee', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('./OverflowMarquee')>();
|
||||
return { ...original, OverflowMarquee: historyRenderProbes.instrument(original.OverflowMarquee, sidebarRenderProbes.row) };
|
||||
});
|
||||
vi.mock('./ProjectActivity', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('./ProjectActivity')>();
|
||||
return { ...original, ProjectActivity: historyRenderProbes.instrument(original.ProjectActivity, sidebarRenderProbes.projectActivity) };
|
||||
});
|
||||
vi.mock('./RuntimeChecklistStrip', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('./RuntimeChecklistStrip')>();
|
||||
return { ...original, RuntimeChecklistStrip: historyRenderProbes.instrument(original.RuntimeChecklistStrip, historyRenderProbes.checklist) };
|
||||
@@ -1221,7 +1230,7 @@ function installRemoteProjectsSetting(enabled: boolean): {
|
||||
updateSource: "github",
|
||||
modelDownloadSource: "modelscope",
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
|
||||
heartbeatEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: enabled,
|
||||
@@ -3098,8 +3107,11 @@ describe("App", () => {
|
||||
await screen.findByText(new RegExp(delta.trim(), "u"));
|
||||
}
|
||||
expect.soft(activityRenderProbe).toHaveBeenCalledTimes(0);
|
||||
activityRenderProbe.mockClear();
|
||||
act(() => agentListener?.({ requestId, type: "done" }));
|
||||
await waitFor(() => expect(activityRenderProbe.mock.calls.length).toBeGreaterThan(0));
|
||||
await waitFor(() => expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument());
|
||||
// The hidden page keeps its snapshot; it refreshes when shown again.
|
||||
expect.soft(activityRenderProbe).toHaveBeenCalledTimes(0);
|
||||
expect(route).toHaveAttribute("hidden");
|
||||
fireEvent.click(screen.getByRole("button", { name: "运行记录" }));
|
||||
expect(screen.getByRole("heading", { name: "运行记录" })).toBe(heading);
|
||||
@@ -3302,7 +3314,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
|
||||
heartbeatEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
@@ -3407,7 +3419,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
|
||||
heartbeatEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
@@ -3424,7 +3436,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
|
||||
heartbeatEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
@@ -3527,7 +3539,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
|
||||
heartbeatEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
@@ -3544,7 +3556,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
|
||||
heartbeatEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
@@ -4564,6 +4576,60 @@ describe("App", () => {
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-renders only the streaming conversation row and keeps sidebar actions live", async () => {
|
||||
const otherConversations = Array.from({ length: 30 }, (_, c) => ({
|
||||
id: `00000000-0000-4000-9${String(c).padStart(3, "0")}-000000000000`,
|
||||
projectId, title: `Sidebar row ${c}`, updatedAt: 1_000 + c,
|
||||
messages: Array.from({ length: 40 }, (_, index) => ({
|
||||
id: `00000000-0000-4000-9${String(c).padStart(3, "0")}-${String(index).padStart(12, "0")}`,
|
||||
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
|
||||
content: `history ${index}`, createdAt: 1_000 + index, state: "complete" as const,
|
||||
})),
|
||||
}));
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce(otherConversations);
|
||||
render(<App />);
|
||||
const composer = await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
await screen.findByText("Sidebar row 29");
|
||||
fireEvent.change(composer, { target: { value: "Row isolation" } });
|
||||
fireEvent.click(await screen.findByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const requestId = run.mock.calls[0]![0].requestId;
|
||||
act(() => agentListener?.({ requestId, type: "text", delta: "Streaming" }));
|
||||
await screen.findByText("Streaming");
|
||||
const rows = () => [...document.querySelectorAll<HTMLElement>(".conversation-entry")];
|
||||
expect(rows().length).toBeGreaterThanOrEqual(30);
|
||||
const rowNodes = rows().map((row) => row.querySelector(".conversation-item__title")!);
|
||||
sidebarRenderProbes.row.mockClear();
|
||||
sidebarRenderProbes.projectActivity.mockClear();
|
||||
for (const delta of [" one", " two", " three"]) {
|
||||
act(() => agentListener?.({ requestId, type: "text", delta }));
|
||||
}
|
||||
await screen.findByText("Streaming one two three");
|
||||
// Only the streaming conversation's own row may re-render per delta.
|
||||
expect(sidebarRenderProbes.row.mock.calls.length).toBeLessThanOrEqual(3);
|
||||
expect(sidebarRenderProbes.projectActivity).toHaveBeenCalledTimes(0);
|
||||
expect(rows().map((row) => row.querySelector(".conversation-item__title"))).toEqual(rowNodes);
|
||||
sidebarRenderProbes.row.mockClear();
|
||||
for (const value of ["a", "ab", "abc"]) {
|
||||
fireEvent.change(composer, { target: { value } });
|
||||
}
|
||||
expect(sidebarRenderProbes.row).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Row actions still run the latest App state after the rows were skipped.
|
||||
const target = screen.getByText("Sidebar row 3").closest(".conversation-entry")!;
|
||||
fireEvent.click(within(target as HTMLElement).getByRole("button", { name: "更多会话操作 Sidebar row 3" }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: /^重命名/u }));
|
||||
const renameInput = within(target as HTMLElement).getByRole("textbox");
|
||||
fireEvent.change(renameInput, { target: { value: "Renamed while streaming" } });
|
||||
fireEvent.submit(renameInput.closest("form")!);
|
||||
const renamedTitle = () => within(target as HTMLElement).getByText("Renamed while streaming");
|
||||
await waitFor(() => expect(renamedTitle()).toBeInTheDocument());
|
||||
fireEvent.click(renamedTitle());
|
||||
await waitFor(() => expect(renamedTitle().closest(".conversation-item")).toHaveClass("conversation-item--active"));
|
||||
expect(document.querySelector(".conversation-title__text")).toHaveTextContent("Renamed while streaming");
|
||||
expect(composer).toHaveValue("");
|
||||
});
|
||||
|
||||
it("isolates history and task strips while typing during a running request", async () => {
|
||||
const conversationId = crypto.randomUUID();
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
|
||||
@@ -5938,7 +6004,7 @@ describe("App", () => {
|
||||
updateSource: "github",
|
||||
modelDownloadSource: "modelscope",
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
|
||||
heartbeatEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
@@ -7919,7 +7985,7 @@ describe("App", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("loads token usage in activity and refreshes it when a run finishes", async () => {
|
||||
it("loads token usage in activity and refreshes it on demand after a run", async () => {
|
||||
vi.mocked(api.usage.getTokenSummary).mockResolvedValueOnce({
|
||||
totals: {
|
||||
callCount: 1,
|
||||
@@ -8009,11 +8075,68 @@ describe("App", () => {
|
||||
type: "done",
|
||||
});
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(api.usage.getTokenSummary).toHaveBeenCalledOnce();
|
||||
expect(within(stats).getByText("120")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "刷新" }));
|
||||
await waitFor(() =>
|
||||
expect(api.usage.getTokenSummary).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
expect(within(stats).getByText("345")).toBeInTheDocument();
|
||||
expect(await within(stats).findByText("345")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refreshes visible activity records every 15 seconds without following live updates", async () => {
|
||||
render(<App />);
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "定时刷新运行记录" },
|
||||
});
|
||||
fireEvent.click(await screen.findByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const requestId = run.mock.calls[0]![0].requestId;
|
||||
const realSetInterval = window.setInterval.bind(window);
|
||||
const refreshTicks: Array<() => void> = [];
|
||||
const intervalSpy = vi
|
||||
.spyOn(window, "setInterval")
|
||||
.mockImplementation(((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
|
||||
if (timeout === 15_000 && typeof handler === "function") {
|
||||
refreshTicks.push(handler as () => void);
|
||||
}
|
||||
return realSetInterval(handler, timeout, ...args);
|
||||
}) as typeof window.setInterval);
|
||||
try {
|
||||
fireEvent.click(screen.getByRole("button", { name: "运行记录" }));
|
||||
await screen.findByRole("heading", { level: 1, name: "运行记录" });
|
||||
await waitFor(() =>
|
||||
expect(api.usage.getTokenSummary).toHaveBeenCalledOnce(),
|
||||
);
|
||||
const items = () =>
|
||||
document.querySelectorAll(".activity-list__item").length;
|
||||
const baseline = items();
|
||||
expect(baseline).toBeGreaterThan(0);
|
||||
act(() =>
|
||||
agentListener?.({
|
||||
requestId,
|
||||
type: "tool",
|
||||
callId: "interval-tool",
|
||||
name: "read",
|
||||
state: "running",
|
||||
summary: "定时刷新工具",
|
||||
}),
|
||||
);
|
||||
// The page keeps its snapshot until the next periodic refresh.
|
||||
expect(items()).toBe(baseline);
|
||||
expect(refreshTicks).toHaveLength(2);
|
||||
act(() => refreshTicks.forEach((tick) => tick()));
|
||||
expect(items()).toBeGreaterThan(baseline);
|
||||
await waitFor(() =>
|
||||
expect(api.usage.getTokenSummary).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
} finally {
|
||||
intervalSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers only Ask and Execute in visible work mode controls", async () => {
|
||||
@@ -13936,7 +14059,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
|
||||
heartbeatEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
@@ -13953,7 +14076,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
|
||||
heartbeatEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
@@ -14007,7 +14130,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
|
||||
heartbeatEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
@@ -14068,7 +14191,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
|
||||
heartbeatEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
@@ -14106,7 +14229,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
|
||||
heartbeatEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
@@ -14123,7 +14246,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
|
||||
heartbeatEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
@@ -14186,7 +14309,7 @@ describe("App", () => {
|
||||
updateSource: "github",
|
||||
modelDownloadSource: "modelscope",
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
|
||||
heartbeatEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
@@ -14246,7 +14369,7 @@ describe("App", () => {
|
||||
updateSource: "github",
|
||||
modelDownloadSource: "modelscope",
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
|
||||
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
|
||||
heartbeatEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
@@ -14328,14 +14451,14 @@ describe("App", () => {
|
||||
await waitFor(() => expect(move).toBeEnabled())
|
||||
fireEvent.click(move)
|
||||
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
|
||||
'监督者', '知识库', '魔法笔记', '本机推理监控', '设备共享',
|
||||
'监督者', '知识库', '魔法笔记', '本机推理监控', '设备共享(技术预览)',
|
||||
]))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '上移 本机推理监控' })).toBeEnabled())
|
||||
const cards = within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article')
|
||||
fireEvent.dragStart(cards[3]!)
|
||||
fireEvent.drop(cards[1]!)
|
||||
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
|
||||
'监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享',
|
||||
'监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)',
|
||||
]))
|
||||
expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes', 'device-sharing'])
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
|
||||
@@ -14343,23 +14466,67 @@ describe("App", () => {
|
||||
'对话', '监督者', '知识库', '魔法笔记', '运行记录',
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享', '管理应用'])
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '管理应用'])
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享'])
|
||||
expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)'])
|
||||
})
|
||||
|
||||
it('opens device sharing from Application Center while keeping the Settings entry available', async () => {
|
||||
it('leaves device sharing on an external settings event and keeps it closed when re-enabled', async () => {
|
||||
const updates = api.updates!
|
||||
await updates.updateSettings({ deviceSharingEnabled: true })
|
||||
let changed!: Parameters<typeof updates.onSettingsChanged>[0]
|
||||
vi.mocked(updates.onSettingsChanged).mockImplementation(listener => { changed = listener; return vi.fn() })
|
||||
render(<App />)
|
||||
await screen.findByRole('button', { name: '监督者' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '设备共享(技术预览)' }))
|
||||
await screen.findByRole('button', { name: '注册本机' })
|
||||
await act(async () => changed(await updates.updateSettings({ deviceSharingEnabled: false })))
|
||||
expect(screen.queryByRole('heading', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '对话' })).toHaveAttribute('aria-current', 'page')
|
||||
await act(async () => changed(await updates.updateSettings({ deviceSharingEnabled: true })))
|
||||
expect(screen.queryByRole('heading', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
expect(screen.getByRole('menuitem', { name: '设备共享(技术预览)' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('enables device sharing from Application Center and leaves its page when disabled', async () => {
|
||||
render(<App />)
|
||||
await screen.findByRole('button', { name: '监督者' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
expect(screen.queryByRole('menuitem', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
const row = screen.getByText('设备共享', { exact: true }).closest('article')!
|
||||
fireEvent.click(within(row).getByRole('button', { name: '打开' }))
|
||||
const row = screen.getByText('设备共享(技术预览)', { exact: true }).closest('article')!
|
||||
expect(within(row).getByRole('button', { name: '打开' })).toBeDisabled()
|
||||
fireEvent.click(within(row).getByRole('button', { name: '设备共享(技术预览) 应用设置' }))
|
||||
const enable = screen.getByRole('switch', { name: '启用应用' })
|
||||
await waitFor(() => expect(enable).toBeEnabled())
|
||||
expect(enable).not.toBeChecked()
|
||||
expect(screen.queryByRole('switch', { name: '常驻左侧菜单' })).not.toBeInTheDocument()
|
||||
fireEvent.click(enable)
|
||||
await waitFor(() => expect(enable).toBeChecked())
|
||||
expect(api.updates!.updateSettings).toHaveBeenCalledWith({ deviceSharingEnabled: true })
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '设备共享(技术预览)' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: '设备共享' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: '设备共享(技术预览)' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '注册本机' })).toBeEnabled()
|
||||
})
|
||||
expect(screen.queryByRole('dialog', { name: '应用中心' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '设备共享(技术预览) 应用设置' }))
|
||||
const disable = screen.getByRole('switch', { name: '启用应用' })
|
||||
await waitFor(() => expect(disable).toBeEnabled())
|
||||
fireEvent.click(disable)
|
||||
await waitFor(() => expect(disable).not.toBeChecked())
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
|
||||
expect(screen.queryByRole('heading', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '注册本机' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
expect(screen.queryByRole('menuitem', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置' }))
|
||||
expect(await screen.findByRole('dialog', { name: '设置中心' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
+690
-502
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,31 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('Application Center', () => {
|
||||
it('offers device sharing enablement without pinning and waits for confirmed settings', () => {
|
||||
const handlers = props()
|
||||
const { rerender } = render(<ApplicationCenter {...handlers} />)
|
||||
expect(isApplicationEnabled(undefined, 'device-sharing')).toBe(false)
|
||||
const card = screen.getByText('设备共享(技术预览)', { exact: true }).closest('article')!
|
||||
expect(within(card).getByText('可选 · 已关闭')).toBeVisible()
|
||||
expect(within(card).getByRole('button', { name: '打开' })).toBeDisabled()
|
||||
fireEvent.click(within(card).getByRole('button', { name: '设备共享(技术预览) 应用设置' }))
|
||||
const enable = screen.getByRole('switch', { name: '启用应用' })
|
||||
expect(enable).not.toBeChecked()
|
||||
expect(screen.queryByRole('switch', { name: '常驻左侧菜单' })).not.toBeInTheDocument()
|
||||
fireEvent.click(enable)
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ deviceSharingEnabled: true })
|
||||
expect(enable).not.toBeChecked()
|
||||
rerender(<ApplicationCenter {...handlers} pending />)
|
||||
expect(enable).toBeDisabled()
|
||||
rerender(<ApplicationCenter {...handlers} settings={{ ...settings, deviceSharingEnabled: true }} />)
|
||||
expect(enable).toBeChecked()
|
||||
fireEvent.click(enable)
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ deviceSharingEnabled: false })
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回应用中心' }))
|
||||
fireEvent.click(within(screen.getByText('设备共享(技术预览)', { exact: true }).closest('article')!).getByRole('button', { name: '打开' }))
|
||||
expect(handlers.onOpen).toHaveBeenCalledWith('device-sharing')
|
||||
})
|
||||
|
||||
it.each(['zh-CN', 'en-US'] as const)('keeps disable consequences visible and pin help separate in %s', async (locale) => {
|
||||
await changeUiLocale(locale)
|
||||
const onUpdate = vi.fn(async () => true)
|
||||
@@ -238,9 +263,9 @@ describe('Application Center', () => {
|
||||
const handlers = props()
|
||||
const { rerender } = render(<ApplicationCenter {...handlers} settings={pinnedSettings} />)
|
||||
const displayedOrder = () => screen.getAllByRole('article').map((row) => row.querySelector('strong')?.textContent)
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享'])
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)'])
|
||||
expect(screen.getByRole('button', { name: '上移 知识库' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '下移 设备共享' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled()
|
||||
const moveUp = screen.getByRole('button', { name: '上移 本机推理监控' })
|
||||
expect(moveUp.closest('article')).not.toBeNull()
|
||||
expect(moveUp).toHaveAttribute('title', '上移 本机推理监控')
|
||||
@@ -251,15 +276,15 @@ describe('Application Center', () => {
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
|
||||
},
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '下移 设备共享' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled()
|
||||
expect(await screen.findByRole('status')).not.toBeEmptyDOMElement()
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享'])
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)'])
|
||||
rerender(<ApplicationCenter {...handlers} settings={{
|
||||
...pinnedSettings,
|
||||
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'] },
|
||||
}} />)
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记', '设备共享'])
|
||||
expect(screen.getByRole('button', { name: '下移 设备共享' })).toBeDisabled()
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记', '设备共享(技术预览)'])
|
||||
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '下移 本机推理监控' }))
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: pinnedSettings.applicationNavigation })
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||
|
||||
@@ -32,7 +32,7 @@ import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
|
||||
import './application-center.css'
|
||||
|
||||
export const applicationDefinitions = {
|
||||
'device-sharing': { icon: Network, title: 'sharing.title' },
|
||||
'device-sharing': { icon: Network, title: 'sharing.title', enabled: 'deviceSharingEnabled' },
|
||||
'magic-notes': {
|
||||
icon: Sparkles,
|
||||
title: 'navigation.magicNotes',
|
||||
@@ -58,7 +58,8 @@ export function isApplicationEnabled(
|
||||
settings: ApplicationSettings | undefined,
|
||||
id: BuiltInApplicationId,
|
||||
): boolean {
|
||||
if (id === 'knowledge' || id === 'device-sharing') return true
|
||||
if (id === 'knowledge') return true
|
||||
if (id === 'device-sharing') return settings?.deviceSharingEnabled === true
|
||||
if (id === 'heartbeat') return settings?.heartbeatEnabled === true
|
||||
return (
|
||||
settings !== undefined &&
|
||||
@@ -121,7 +122,7 @@ export function ApplicationSettingsView({
|
||||
/>
|
||||
<span>{t('applications.enable')}</span>
|
||||
</label>
|
||||
<div className="inline-help-label">
|
||||
{id !== 'device-sharing' && <div className="inline-help-label">
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
role="switch"
|
||||
@@ -141,8 +142,8 @@ export function ApplicationSettingsView({
|
||||
<span>{t('applications.pin')}</span>
|
||||
</label>
|
||||
<InlineHelp label={t('applications.pin')} id="application-pin-help">{t('applications.pinHelp')}</InlineHelp>
|
||||
</div>
|
||||
<p className="settings-notice">{t('applications.disableHelp')}</p>
|
||||
</div>}
|
||||
<p className="settings-notice">{t(id === 'device-sharing' ? 'applications.deviceSharingHelp' : 'applications.disableHelp')}</p>
|
||||
{id === 'magic-notes' && (
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
@@ -451,7 +452,7 @@ export function ApplicationCenter({
|
||||
<div className="application-center__title">
|
||||
<strong>{t(definition.title)}</strong>
|
||||
<small>
|
||||
{id === 'device-sharing' ? t('applications.builtin') : alwaysShown
|
||||
{alwaysShown
|
||||
? t('applications.alwaysShown')
|
||||
: `${t('applications.optional')} · ${t(enabled ? 'applications.enabled' : 'applications.disabled')}`}
|
||||
</small>
|
||||
@@ -468,7 +469,7 @@ export function ApplicationCenter({
|
||||
>
|
||||
{t('applications.open')}
|
||||
</button>
|
||||
{id !== 'knowledge' && id !== 'device-sharing' && (
|
||||
{id !== 'knowledge' && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
|
||||
@@ -22,6 +22,19 @@ function Harness({ value = settings, pending = false, error, onOpen = vi.fn(), o
|
||||
}
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
it('only shows device sharing when explicitly enabled and removes it on disable', () => {
|
||||
const onOpen = vi.fn()
|
||||
const { rerender } = render(<Harness onOpen={onOpen} />)
|
||||
fireEvent.click(screen.getByText('Launcher'))
|
||||
expect(screen.queryByRole('menuitem', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
|
||||
rerender(<Harness value={{ ...settings, deviceSharingEnabled: true }} onOpen={onOpen} />)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '设备共享(技术预览)' }))
|
||||
expect(onOpen).toHaveBeenCalledWith('device-sharing')
|
||||
fireEvent.click(screen.getByText('Launcher'))
|
||||
rerender(<Harness value={{ ...settings, deviceSharingEnabled: false }} onOpen={onOpen} />)
|
||||
expect(screen.queryByRole('menuitem', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters the shared order by enablement only, including reordered always-shown apps', () => {
|
||||
const value = { ...settings, magicNotesEnabled: true, localInferenceEnabled: true, heartbeatEnabled: true,
|
||||
applicationNavigation: { order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'] as ApplicationSettings['applicationNavigation']['order'],
|
||||
|
||||
@@ -268,7 +268,7 @@ describe('EmbeddingSettingsSection', () => {
|
||||
).not.toHaveAttribute('value')
|
||||
})
|
||||
|
||||
it('keeps an unavailable source explicit and opens General settings', () => {
|
||||
it('keeps an unavailable source explicit and opens source settings', () => {
|
||||
const onOpenModelDownloadSourceSettings = vi.fn()
|
||||
renderSection({
|
||||
models: {
|
||||
@@ -298,7 +298,7 @@ describe('EmbeddingSettingsSection', () => {
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '前往通用设置' })
|
||||
screen.getByRole('button', { name: '模型下载源' })
|
||||
)
|
||||
expect(onOpenModelDownloadSourceSettings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -77,7 +77,7 @@ const settings: ApplicationSettings = {
|
||||
transparentFrostedEffectEnabled: false,
|
||||
checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings, applicationNavigation: defaultApplicationNavigation,
|
||||
localInferenceEnabled: true, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false,
|
||||
localInferenceEnabled: true, deviceSharingEnabled: false, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true,
|
||||
magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined', magicNoteCanvasPageCount: 1
|
||||
}
|
||||
|
||||
@@ -18,9 +18,12 @@ import { changeUiLocale } from './i18n'
|
||||
import {
|
||||
InlineMarkdown,
|
||||
MarkdownRenderer,
|
||||
normalizeLatexDelimiters
|
||||
normalizeLatexDelimiters,
|
||||
splitMarkdownSegments,
|
||||
UnsegmentedMarkdownRenderer
|
||||
} from './MarkdownRenderer'
|
||||
|
||||
|
||||
const mermaidMock = vi.hoisted(() => ({
|
||||
initialize: vi.fn(),
|
||||
render: vi.fn()
|
||||
@@ -721,3 +724,96 @@ ${'A'.repeat(20_001)}
|
||||
await changeUiLocale('zh-CN')
|
||||
})
|
||||
})
|
||||
|
||||
describe('segmented Markdown rendering', () => {
|
||||
afterEach(() => cleanup())
|
||||
|
||||
const prose = ('A growing paragraph with **emphasis**, `inline code`, and [a link](https://example.com).\n\n' +
|
||||
'- First item\n- Second item\n\n> A quoted explanation.\n\n').repeat(8)
|
||||
const segmentationCorpus = [
|
||||
'# Heading\n\n**bold** and [link](https://example.com)\n- [x] ready\n',
|
||||
'Inline \\(a+b\\), $x$ and \\\\(escaped\\).\n$x^2$\n\\(y\\)\n\\[z\\]\n\\[\na+b\n\\]\n',
|
||||
'`\\(code\\)` ``a ` \\(b\\)`` and \\(x\\)\n```tex\n\\(x\\)\n```\n~~~\n$y$\n~~~\n',
|
||||
'````tex\n```\n\\[x\\]\n````\n ~~~txt\n\\(y\\)\n ~~~~\n\\(z\\)\n',
|
||||
'a\r\nb\r\n$x$\r\n```txt\r\n\\(code\\)\r\n```\r\nend\r',
|
||||
'partial \\(x + \\) then `code \\( and `` and \\[\n\\',
|
||||
'\n\nplain\rtext\r\n\n $indented$\n \\(indented\\)\n',
|
||||
'# Streaming answer\n\n' + prose,
|
||||
prose.slice(0, 600) + '\n\\(x^2 + y^2\\)\n\n```ts\nconst value = "\\(literal\\)"\n```\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n',
|
||||
'# 标题\n\n第一段 **粗体**。\n\n第二段 `code`。\n\n## 小节\n\n最后一段。',
|
||||
'- a\n- b\n\n- c\n\nafter list\n\n1. one\n2. two\n\n3) three\n\npara',
|
||||
'- item\n\n continued paragraph\n\n indented code in item\n\nend',
|
||||
'> quote\n\n> second quote\n\nplain\n\n>lazy\ncontinuation',
|
||||
'para\n\n indented code\n\n more code\n\nafter',
|
||||
'```ts\nconst a = 1\n\nconst b = 2\n```\n\ntext\n\n~~~\n\n~~~\n\nend',
|
||||
'````\n```\n\ninner\n```\n````\n\nafter fence\n\n```\nunterminated\n\nstill code',
|
||||
'$$\na^2\n\n+ b^2\n$$\n\ntext with $x$ math\n\n\\[\ny\n\\]\n\nend',
|
||||
'| a | b |\n| - | - |\n| 1 | 2 |\n\n| c |\n| - |\n| 3 |\n\nafter',
|
||||
'Setext\n===\n\nAnother\n---\n\n***\n\n- - -\n\nend',
|
||||
'see [ref] and [^note]\n\n[ref]: https://example.com\n\n[^note]: footnote body',
|
||||
'- [ ] task\n- [x] done\n\n~~strike~~ and https://example.com autolink',
|
||||
'<div>\n\nraw html block\n\n</div>\n\nafter html',
|
||||
'\n\n\nleading blanks\n\n\n\nmany blanks\n\n',
|
||||
' indented by three\n\n\tafter tab\n\n - nested\n\ntrailing',
|
||||
'```a`b\n\ntext\n\n```\n\ncode?\n\n```\n\nend',
|
||||
'- item\n\n ```\n x\n\n y\n ```\n\nafter\n\n> ```\n\nq',
|
||||
'1. one\n\n $$\n a\n\n $$\n\nb\n\n10. ten\n\nc'
|
||||
]
|
||||
|
||||
function canonicalHtml(element: HTMLElement): string {
|
||||
const ids = new Map<string, string>()
|
||||
const clone = element.cloneNode(true) as HTMLElement
|
||||
for (const node of clone.querySelectorAll('*')) {
|
||||
for (const name of ['id', 'aria-controls', 'aria-labelledby', 'aria-describedby', 'href']) {
|
||||
const value = node.getAttribute(name)
|
||||
if (value && /_r_[a-z0-9]+_|user-content-fn/u.test(value)) {
|
||||
if (!ids.has(value)) ids.set(value, `id-${ids.size}`)
|
||||
node.setAttribute(name, ids.get(value)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
return clone.innerHTML
|
||||
}
|
||||
|
||||
it('renders every streaming prefix identically to whole-document parsing', () => {
|
||||
const segmented = render(<div />)
|
||||
const reference = render(<div />)
|
||||
let prefixes = 0
|
||||
let multiSegment = 0
|
||||
for (const content of segmentationCorpus) {
|
||||
const step = Math.max(1, Math.floor(content.length / 120))
|
||||
for (let end = 0; end <= content.length; end += step) {
|
||||
const prefix = content.slice(0, end)
|
||||
segmented.rerender(<div><MarkdownRenderer>{prefix}</MarkdownRenderer></div>)
|
||||
reference.rerender(<div><UnsegmentedMarkdownRenderer>{prefix}</UnsegmentedMarkdownRenderer></div>)
|
||||
expect(canonicalHtml(segmented.container), JSON.stringify(prefix))
|
||||
.toBe(canonicalHtml(reference.container))
|
||||
prefixes++
|
||||
if (splitMarkdownSegments(normalizeLatexDelimiters(prefix)).length > 1) multiSegment++
|
||||
}
|
||||
}
|
||||
expect(prefixes).toBeGreaterThan(1500)
|
||||
expect(multiSegment).toBeGreaterThan(500)
|
||||
})
|
||||
|
||||
it('keeps completed segments intact and falls back for document-scoped syntax', () => {
|
||||
// List markers never start a segment: the list may continue or turn loose.
|
||||
expect(splitMarkdownSegments('a\n\nb\n\n- c\n\n- d\n\ne')).toEqual(['a\n\n', 'b\n\n- c\n\n- d\n\n', 'e'])
|
||||
expect(splitMarkdownSegments('```\n\nx\n\n```\n\ny')).toEqual(['```\n\nx\n\n```\n\n', 'y'])
|
||||
expect(splitMarkdownSegments('a\n\n[x]: https://example.com')).toHaveLength(1)
|
||||
expect(splitMarkdownSegments('a\n\n<div>\n\nb\n\n</div>')).toHaveLength(1)
|
||||
expect(splitMarkdownSegments('a\r\n\r\nb')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('re-parses only the growing tail when completed segments are unchanged', () => {
|
||||
const base = Array.from({ length: 30 }, (_, i) => `段落 ${i} **粗体** $x_${i}$`).join('\n\n')
|
||||
const { rerender, container } = render(<MarkdownRenderer>{base}</MarkdownRenderer>)
|
||||
const firstParagraph = container.querySelector('p')!
|
||||
rerender(<MarkdownRenderer>{`${base} 追加`}</MarkdownRenderer>)
|
||||
rerender(<MarkdownRenderer>{`${base} 追加\n\n新段落`}</MarkdownRenderer>)
|
||||
// Untouched blocks keep their DOM nodes: they were not re-rendered.
|
||||
expect(container.querySelector('p')).toBe(firstParagraph)
|
||||
expect(container.querySelectorAll('p')).toHaveLength(31)
|
||||
expect(container.querySelectorAll('.katex')).toHaveLength(30)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Children,
|
||||
Fragment,
|
||||
isValidElement,
|
||||
lazy,
|
||||
memo,
|
||||
@@ -254,10 +255,154 @@ function standaloneHtmlSource(content: string): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({
|
||||
// Link reference and footnote definitions resolve across the whole document,
|
||||
// even from inside lists or block quotes, so their presence disables splitting.
|
||||
const documentScopedMarkdown = /\]:|\[\^/u
|
||||
// Constructs that may legally span blank lines in ways a line scanner cannot
|
||||
// track: raw HTML blocks and `$$` lines carrying other text.
|
||||
const unsafeSegmentLine = /^ {0,3}(?:<|\$\$.*\S)/u
|
||||
// CommonMark: a backtick fence's info string cannot contain backticks.
|
||||
const segmentFenceOpening = /^ {0,3}(?:(`{3,})(?!.*`)|(~{3,}))/u
|
||||
const segmentMathFence = /^ {0,3}\$\$\s*$/u
|
||||
// A block after a blank line belongs to the previous top-level block when it
|
||||
// is indented (list item or indented code continuation), a list marker (the
|
||||
// list may continue and its looseness may change), or a block quote marker.
|
||||
const continuationLine = /^(?:\s|[-*+](?:\s|$)|\d{1,9}[.)](?:\s|$)|>)/u
|
||||
|
||||
/**
|
||||
* Splits Markdown at top-level boundaries where parsing each segment
|
||||
* independently produces exactly the same blocks as parsing the whole text:
|
||||
* a blank line, outside fenced code and math, followed by a line that starts
|
||||
* a new top-level block. Returns a single segment whenever any construct
|
||||
* could make that unsafe.
|
||||
*/
|
||||
export function splitMarkdownSegments(content: string): string[] {
|
||||
if (documentScopedMarkdown.test(content) || content.includes('\r')) {
|
||||
return [content]
|
||||
}
|
||||
const segments: string[] = []
|
||||
let fence: { character: '`' | '~'; length: number } | undefined
|
||||
let inMath = false
|
||||
let segmentStart = 0
|
||||
let segmentHasBlock = false
|
||||
let previousBlank = false
|
||||
let lineStart = 0
|
||||
|
||||
while (lineStart <= content.length) {
|
||||
const newline = content.indexOf('\n', lineStart)
|
||||
const lineEnd = newline === -1 ? content.length : newline
|
||||
const line = content.slice(lineStart, lineEnd)
|
||||
|
||||
if (fence) {
|
||||
const closing = new RegExp(
|
||||
`^ {0,3}${fence.character}{${fence.length},}\\s*$`,
|
||||
'u'
|
||||
)
|
||||
if (closing.test(line)) {
|
||||
fence = undefined
|
||||
}
|
||||
previousBlank = false
|
||||
} else if (inMath) {
|
||||
if (segmentMathFence.test(line)) {
|
||||
inMath = false
|
||||
}
|
||||
previousBlank = false
|
||||
} else if (line.trim() === '') {
|
||||
previousBlank = true
|
||||
} else {
|
||||
if (unsafeSegmentLine.test(line)) {
|
||||
return [content]
|
||||
}
|
||||
// Leading blank lines render nothing; a segment of them would add an
|
||||
// extra separator, so split only after the segment has a block.
|
||||
if (
|
||||
previousBlank &&
|
||||
segmentHasBlock &&
|
||||
!continuationLine.test(line)
|
||||
) {
|
||||
segments.push(content.slice(segmentStart, lineStart))
|
||||
segmentStart = lineStart
|
||||
}
|
||||
segmentHasBlock = true
|
||||
const opening = segmentFenceOpening.exec(line)
|
||||
if (opening) {
|
||||
const marker = (opening[1] ?? opening[2])!
|
||||
fence = { character: marker[0] as '`' | '~', length: marker.length }
|
||||
} else if (/^ {0,3}`{3,}/u.test(line)) {
|
||||
// Backtick run with a backtick in its info string: inline code that
|
||||
// the line scanner cannot model, so keep the document whole.
|
||||
return [content]
|
||||
} else if (segmentMathFence.test(line)) {
|
||||
inMath = true
|
||||
}
|
||||
previousBlank = false
|
||||
}
|
||||
|
||||
if (newline === -1) {
|
||||
break
|
||||
}
|
||||
lineStart = newline + 1
|
||||
}
|
||||
|
||||
segments.push(content.slice(segmentStart))
|
||||
return segments
|
||||
}
|
||||
|
||||
const markdownRehypePlugins: NonNullable<
|
||||
React.ComponentProps<typeof ReactMarkdown>['rehypePlugins']
|
||||
> = [
|
||||
[
|
||||
rehypeKatex,
|
||||
{
|
||||
output: 'htmlAndMathml',
|
||||
strict: 'warn',
|
||||
trust: false
|
||||
}
|
||||
]
|
||||
]
|
||||
const markdownRemarkPlugins: NonNullable<
|
||||
React.ComponentProps<typeof ReactMarkdown>['remarkPlugins']
|
||||
> = [remarkGfm, [remarkMath, { singleDollarTextMath: true }]]
|
||||
|
||||
const MarkdownSegment = memo(function MarkdownSegment({
|
||||
components,
|
||||
content
|
||||
}: {
|
||||
components: Components
|
||||
content: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
components={components}
|
||||
rehypePlugins={markdownRehypePlugins}
|
||||
remarkPlugins={markdownRemarkPlugins}
|
||||
skipHtml
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
})
|
||||
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer(
|
||||
props: MarkdownRendererProps
|
||||
): React.JSX.Element {
|
||||
return <MarkdownDocument {...props} segmented />
|
||||
})
|
||||
|
||||
/** Whole-document reference rendering; used to verify segmentation. */
|
||||
export const UnsegmentedMarkdownRenderer = memo(
|
||||
function UnsegmentedMarkdownRenderer(
|
||||
props: MarkdownRendererProps
|
||||
): React.JSX.Element {
|
||||
return <MarkdownDocument {...props} segmented={false} />
|
||||
}
|
||||
)
|
||||
|
||||
function MarkdownDocument({
|
||||
children,
|
||||
renderHtml = false
|
||||
}: MarkdownRendererProps): React.JSX.Element {
|
||||
renderHtml = false,
|
||||
segmented
|
||||
}: MarkdownRendererProps & { segmented: boolean }): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const normalizedContent = normalizeLatexDelimiters(
|
||||
unwrapMarkdownFence(children)
|
||||
@@ -283,26 +428,20 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
|
||||
)
|
||||
}
|
||||
|
||||
// Completed segments keep identical strings, so streaming updates re-parse
|
||||
// only the growing tail. The "\n" matches the text node ReactMarkdown
|
||||
// places between top-level blocks, keeping the DOM identical.
|
||||
const segments = segmented
|
||||
? splitMarkdownSegments(normalizedContent)
|
||||
: [normalizedContent]
|
||||
return (
|
||||
<ReactMarkdown
|
||||
components={components}
|
||||
rehypePlugins={[
|
||||
[
|
||||
rehypeKatex,
|
||||
{
|
||||
output: 'htmlAndMathml',
|
||||
strict: 'warn',
|
||||
trust: false
|
||||
}
|
||||
]
|
||||
]}
|
||||
remarkPlugins={[
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: true }]
|
||||
]}
|
||||
skipHtml
|
||||
>
|
||||
{normalizedContent}
|
||||
</ReactMarkdown>
|
||||
<>
|
||||
{segments.map((segment, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 && '\n'}
|
||||
<MarkdownSegment components={components} content={segment} />
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ModelDownloadSource } from '../../shared/application-settings-contracts'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
|
||||
export function ModelDownloadSourceSettings({
|
||||
open, onOpen, onClose, onNotify, onSourceChanged
|
||||
}: {
|
||||
open: boolean
|
||||
onOpen: () => void
|
||||
onClose: () => void
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
onSourceChanged: (source: ModelDownloadSource) => void
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('settingsSections')
|
||||
const [source, setSource] = useState<ModelDownloadSource>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string>()
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const trigger = useRef<HTMLButtonElement>(null)
|
||||
const returnFocus = useCallback(() => trigger.current, [])
|
||||
const receiveSource = useCallback((value: ModelDownloadSource) => {
|
||||
setSource(value)
|
||||
onSourceChanged(value)
|
||||
}, [onSourceChanged])
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const updates = window.goodbuddy.updates
|
||||
// A settings event is newer than the initial read still in flight.
|
||||
let receivedChange = false
|
||||
const unsubscribe = updates?.onSettingsChanged((settings) => {
|
||||
receivedChange = true
|
||||
receiveSource(settings.modelDownloadSource)
|
||||
setError(undefined)
|
||||
setLoading(false)
|
||||
})
|
||||
void (async () => {
|
||||
try {
|
||||
if (!updates) throw new Error('unavailable')
|
||||
const settings = await updates.getSettings()
|
||||
if (active && !receivedChange) {
|
||||
receiveSource(settings.modelDownloadSource)
|
||||
}
|
||||
} catch {
|
||||
if (active && !receivedChange) {
|
||||
setError(t('platformFeatures.modelDownloadSource.readFailed'))
|
||||
}
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [attempt, t, receiveSource])
|
||||
const retry = (): void => {
|
||||
setLoading(true)
|
||||
setError(undefined)
|
||||
setAttempt((value) => value + 1)
|
||||
}
|
||||
return (
|
||||
<div className="model-download-source-control">
|
||||
<small role={loading ? 'status' : undefined}>
|
||||
{source
|
||||
? t('platformFeatures.modelDownloadSource.current', {
|
||||
source: t(`modelDownloadSources.${source}`)
|
||||
})
|
||||
: loading
|
||||
? t('platformFeatures.loading')
|
||||
: t('platformFeatures.modelDownloadSource.readFailed')}
|
||||
</small>
|
||||
<button className="secondary-button" onClick={onOpen} ref={trigger} type="button">
|
||||
{t('platformFeatures.modelDownloadSource.title')}
|
||||
</button>
|
||||
{open && (
|
||||
<ModelDownloadSourceDialog
|
||||
source={source}
|
||||
loading={loading}
|
||||
loadError={error}
|
||||
onRetry={retry}
|
||||
onClose={onClose}
|
||||
onSaved={receiveSource}
|
||||
onNotify={onNotify}
|
||||
returnFocus={returnFocus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelDownloadSourceDialog({
|
||||
source, loading, loadError, onRetry, onClose, onSaved, onNotify, returnFocus
|
||||
}: {
|
||||
source?: ModelDownloadSource
|
||||
loading: boolean
|
||||
loadError?: string
|
||||
onRetry: () => void
|
||||
onClose: () => void
|
||||
onSaved: (source: ModelDownloadSource) => void
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
returnFocus: () => HTMLElement | null
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('settingsSections')
|
||||
const { t: settingsT } = useTranslation('settings')
|
||||
const [draft, setDraft] = useState(source)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const closeButton = useRef<HTMLButtonElement>(null)
|
||||
useEffect(() => activateModalFocus(() => closeButton.current, returnFocus), [returnFocus])
|
||||
const selection = draft ?? source
|
||||
const close = (): void => {
|
||||
if (!saving) onClose()
|
||||
}
|
||||
const confirm = async (): Promise<void> => {
|
||||
if (saving || loading || !selection || loadError) return
|
||||
if (selection === source) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const updates = window.goodbuddy.updates
|
||||
if (!updates) throw new Error('unavailable')
|
||||
const settings = await updates.updateSettings({ modelDownloadSource: selection })
|
||||
onSaved(settings.modelDownloadSource)
|
||||
onNotify?.({
|
||||
tone: 'success',
|
||||
dedupeKey: 'model-download-source',
|
||||
message: t('platformFeatures.modelDownloadSource.notification', {
|
||||
source: t(`modelDownloadSources.${settings.modelDownloadSource}`)
|
||||
})
|
||||
})
|
||||
onClose()
|
||||
} catch {
|
||||
setError(t('platformFeatures.errors.saveModelDownloadSourceFailed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
return createPortal(
|
||||
<div className="custom-task-dialog" onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
event.preventDefault()
|
||||
close()
|
||||
}
|
||||
}}>
|
||||
<section
|
||||
className="custom-task-dialog__surface"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabIndex={-1}
|
||||
aria-labelledby="model-download-source-title"
|
||||
aria-describedby="model-download-source-description"
|
||||
aria-busy={saving || loading}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
trapTabFocus(event, event.currentTarget)
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
close()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header className="custom-task-dialog__header">
|
||||
<h2 id="model-download-source-title">{t('platformFeatures.modelDownloadSource.title')}</h2>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
ref={closeButton}
|
||||
disabled={saving}
|
||||
onClick={close}
|
||||
aria-label={t('platformFeatures.modelDownloadSource.close')}
|
||||
title={t('platformFeatures.modelDownloadSource.close')}
|
||||
>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="custom-task-dialog__content">
|
||||
<p id="model-download-source-description">{t('platformFeatures.modelDownloadSource.description')}</p>
|
||||
{loading ? (
|
||||
<p role="status">{t('platformFeatures.loading')}</p>
|
||||
) : loadError ? (
|
||||
<div>
|
||||
<p role="alert">{loadError}</p>
|
||||
<button className="secondary-button" type="button" onClick={onRetry}>
|
||||
{t('platformFeatures.modelDownloadSource.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<fieldset
|
||||
className="model-download-source"
|
||||
disabled={saving}
|
||||
aria-describedby={error ? 'model-download-source-error' : undefined}
|
||||
>
|
||||
<legend className="sr-only">{t('platformFeatures.modelDownloadSource.title')}</legend>
|
||||
{(['modelscope', 'hugging-face'] as const).map((value) => (
|
||||
<label
|
||||
key={value}
|
||||
className={`model-download-source__option${selection === value ? ' model-download-source__option--selected' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="model-download-source"
|
||||
value={value}
|
||||
checked={selection === value}
|
||||
onChange={() => {
|
||||
setDraft(value)
|
||||
setError(undefined)
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>{t(`modelDownloadSources.${value}`)}</strong>
|
||||
<small>{t(`platformFeatures.modelDownloadSource.options.${value}`)}</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
{error && <p className="settings-warning" id="model-download-source-error" role="alert">{error}</p>}
|
||||
</div>
|
||||
<footer className="custom-task-dialog__actions">
|
||||
<button className="secondary-button" type="button" disabled={saving} onClick={close}>
|
||||
{settingsT('actions.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
type="button"
|
||||
disabled={saving || loading || !selection || Boolean(loadError)}
|
||||
onClick={() => void confirm()}
|
||||
>
|
||||
{saving ? settingsT('actions.saving') : t('platformFeatures.modelDownloadSource.confirm')}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -16,8 +16,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import type {
|
||||
ApplicationSettings,
|
||||
ModelDownloadSource
|
||||
ApplicationSettings
|
||||
} from '../../shared/application-settings-contracts'
|
||||
import type {
|
||||
AgentPackageDownloadProgress,
|
||||
@@ -94,7 +93,6 @@ export function PlatformFeaturesSettingsSection({
|
||||
: t('platformFeatures.shortcut.errors.serviceUnavailable')
|
||||
)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [sourceError, setSourceError] = useState<string>()
|
||||
const [agentInventory, setAgentInventory] =
|
||||
useState<AgentPackageInventory>()
|
||||
const [agentInventoryLoading, setAgentInventoryLoading] =
|
||||
@@ -426,45 +424,6 @@ export function PlatformFeaturesSettingsSection({
|
||||
}
|
||||
}
|
||||
|
||||
const changeModelDownloadSource = async (
|
||||
modelDownloadSource: ModelDownloadSource
|
||||
): Promise<void> => {
|
||||
const updates = window.goodbuddy.updates
|
||||
if (
|
||||
!updates ||
|
||||
!settings ||
|
||||
settings.modelDownloadSource === modelDownloadSource
|
||||
) {
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setSourceError(undefined)
|
||||
try {
|
||||
const nextSettings = await updates.updateSettings({
|
||||
modelDownloadSource
|
||||
})
|
||||
setSettings(nextSettings)
|
||||
onNotify?.({
|
||||
tone: 'success',
|
||||
message: t(
|
||||
'platformFeatures.modelDownloadSource.notification',
|
||||
{
|
||||
source: t(
|
||||
`modelDownloadSources.${nextSettings.modelDownloadSource}`
|
||||
)
|
||||
}
|
||||
),
|
||||
dedupeKey: 'model-download-source'
|
||||
})
|
||||
} catch {
|
||||
setSourceError(
|
||||
t('platformFeatures.errors.saveModelDownloadSourceFailed')
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const changeConversationHtmlRendering = async (
|
||||
enabled: boolean
|
||||
): Promise<void> => {
|
||||
@@ -780,69 +739,6 @@ export function PlatformFeaturesSettingsSection({
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
{settings ? (
|
||||
<>
|
||||
<div className="capability-card__header">
|
||||
<span className="inline-help-label">
|
||||
<strong>
|
||||
{t('platformFeatures.modelDownloadSource.title')}
|
||||
</strong>
|
||||
<InlineHelp label={t('platformFeatures.modelDownloadSource.title')}>
|
||||
{t(
|
||||
'platformFeatures.modelDownloadSource.description'
|
||||
)}
|
||||
</InlineHelp>
|
||||
</span>
|
||||
</div>
|
||||
<fieldset className="model-download-source">
|
||||
<legend className="sr-only">
|
||||
{t('platformFeatures.modelDownloadSource.title')}
|
||||
</legend>
|
||||
{(
|
||||
['modelscope', 'hugging-face'] as const
|
||||
).map((source) => (
|
||||
<label
|
||||
className={
|
||||
source === settings.modelDownloadSource
|
||||
? 'model-download-source__option model-download-source__option--selected'
|
||||
: 'model-download-source__option'
|
||||
}
|
||||
key={source}
|
||||
>
|
||||
<input
|
||||
checked={source === settings.modelDownloadSource}
|
||||
disabled={saving}
|
||||
name="model-download-source"
|
||||
onChange={() =>
|
||||
void changeModelDownloadSource(source)
|
||||
}
|
||||
type="radio"
|
||||
value={source}
|
||||
/>
|
||||
<span>
|
||||
<strong>{t(`modelDownloadSources.${source}`)}</strong>
|
||||
<small>
|
||||
{t(
|
||||
`platformFeatures.modelDownloadSource.options.${source}`
|
||||
)}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
{sourceError && (
|
||||
<p className="settings-warning" role="alert">
|
||||
{sourceError}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
!error && (
|
||||
<p className="settings-notice" role="status">
|
||||
{t('platformFeatures.loading')}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Check, ChevronDown, ChevronLeft, ChevronRight, CircleAlert, LoaderCircle } from 'lucide-react'
|
||||
import { useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal, flushSync } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
@@ -241,7 +241,9 @@ function ActivityMenu({
|
||||
)
|
||||
}
|
||||
|
||||
export function ProjectActivity({
|
||||
// Memoized: App re-renders on every streaming delta and keystroke, while the
|
||||
// activity summary it passes keeps its identity unless an activity changes.
|
||||
export const ProjectActivity = memo(function ProjectActivity({
|
||||
activities,
|
||||
projects,
|
||||
visible = true,
|
||||
@@ -292,4 +294,4 @@ export function ProjectActivity({
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -40,6 +40,7 @@ import type {
|
||||
EmbeddingSettingsSnapshot
|
||||
} from '../../shared/embedding-contracts'
|
||||
import type { SpeechModelSnapshot } from '../../shared/speech-model-contracts'
|
||||
import type { DocumentParsingSnapshot } from '../../shared/document-parsing-contracts'
|
||||
import { builtinMcpServers } from '../../shared/builtin-mcp-servers'
|
||||
import { builtinModelToolGroups } from '../../shared/builtin-model-tools'
|
||||
import {
|
||||
@@ -524,7 +525,7 @@ let applicationSettings: ApplicationSettings = {
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: false,
|
||||
@@ -819,7 +820,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: false,
|
||||
@@ -1403,7 +1404,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: /Platform Features/i })
|
||||
)
|
||||
expect(screen.getByText('Workspace & downloads')).toBeInTheDocument()
|
||||
expect(screen.getByText('Workspace')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByLabelText('Default workspace folder')
|
||||
).toBeInTheDocument()
|
||||
@@ -1507,7 +1508,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(applicationSettings).toEqual(storedSettings)
|
||||
})
|
||||
|
||||
it('switches the global model download source from General settings', async () => {
|
||||
it('confirms the model download source beside the model types, below save actions', async () => {
|
||||
const onNotify = vi.fn()
|
||||
render(
|
||||
<SettingsPanel
|
||||
@@ -1520,8 +1521,15 @@ describe('SettingsPanel runtime files', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
const modelScope = await screen.findByRole('radio', {
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
const trigger = screen.getByRole('button', { name: '模型下载源' })
|
||||
expect(trigger.closest('.model-type-navigation')).toContainElement(screen.getByRole('group', { name: '模型类型' }))
|
||||
expect(trigger.closest('.settings-category-header__navigation')?.previousElementSibling).toHaveClass('settings-category-header__actions')
|
||||
expect(await screen.findByText('当前来源:ModelScope')).toBeInTheDocument()
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
|
||||
const modelScope = within(dialog).getByRole('radio', {
|
||||
name: /ModelScope/u
|
||||
})
|
||||
const huggingFace = screen.getByRole('radio', {
|
||||
@@ -1534,21 +1542,28 @@ describe('SettingsPanel runtime files', () => {
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(huggingFace)
|
||||
expect(updateApplicationSettings).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('当前来源:ModelScope')).toBeInTheDocument()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认' }))
|
||||
await waitFor(() =>
|
||||
expect(updateApplicationSettings).toHaveBeenCalledWith({
|
||||
modelDownloadSource: 'hugging-face'
|
||||
})
|
||||
)
|
||||
expect(huggingFace).toBeChecked()
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('当前来源:Hugging Face')).toBeInTheDocument()
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
expect(onNotify).toHaveBeenCalledWith({
|
||||
tone: 'success',
|
||||
message: '模型下载源已切换为 Hugging Face。',
|
||||
dedupeKey: 'model-download-source'
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '远程项目' })
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
expect(screen.queryByRole('radio', { name: /ModelScope/u })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '模型下载源' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -1630,9 +1645,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
const conversation = await screen.findByRole('article', { name: '会话与通知' })
|
||||
expect(within(conversation).getByRole('switch', { name: '桌面通知' })).not.toBeChecked()
|
||||
expect(within(conversation).getByRole('switch', { name: '在会话中渲染 HTML' })).toBeChecked()
|
||||
const workspace = screen.getByRole('article', { name: '工作目录与下载' })
|
||||
const workspace = screen.getByRole('article', { name: '工作目录' })
|
||||
expect(within(workspace).getByRole('textbox')).toHaveValue(runtimeSettings.workspacePath)
|
||||
expect(within(workspace).getByRole('radio', { name: /ModelScope/u })).toBeChecked()
|
||||
expect(within(workspace).queryByRole('radio')).not.toBeInTheDocument()
|
||||
expect(updateApplicationSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1708,7 +1723,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(toggle).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('does not guess a model download source when settings fail to load', async () => {
|
||||
it('does not guess platform settings when they fail to load', async () => {
|
||||
getApplicationSettings.mockRejectedValueOnce(
|
||||
new Error('read failed')
|
||||
)
|
||||
@@ -1744,7 +1759,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the confirmed model download source when saving fails', async () => {
|
||||
it('keeps the source draft after a failed confirmation and allows retry', async () => {
|
||||
updateApplicationSettings.mockRejectedValueOnce(
|
||||
new Error('save failed')
|
||||
)
|
||||
@@ -1758,7 +1773,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
await screen.findByText('当前来源:ModelScope')
|
||||
fireEvent.click(screen.getByRole('button', { name: '模型下载源' }))
|
||||
const modelScope = await screen.findByRole('radio', {
|
||||
name: /ModelScope/u
|
||||
})
|
||||
@@ -1766,12 +1783,164 @@ describe('SettingsPanel runtime files', () => {
|
||||
name: /Hugging Face/u
|
||||
})
|
||||
fireEvent.click(huggingFace)
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }))
|
||||
|
||||
expect(
|
||||
await screen.findByText('保存模型下载源失败,请重试')
|
||||
).toBeInTheDocument()
|
||||
expect(modelScope).toBeChecked()
|
||||
expect(huggingFace).not.toBeChecked()
|
||||
expect(modelScope).not.toBeChecked()
|
||||
expect(huggingFace).toBeChecked()
|
||||
expect(screen.getByText('当前来源:ModelScope')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('当前来源:Hugging Face')).toBeInTheDocument()
|
||||
expect(updateApplicationSettings).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['cancel', 'escape', 'close', 'backdrop'])('discards source selection on %s without saving', async (method) => {
|
||||
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
await screen.findByText('当前来源:ModelScope')
|
||||
const trigger = screen.getByRole('button', { name: '模型下载源' })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
|
||||
fireEvent.click(within(dialog).getByRole('radio', { name: /Hugging Face/u }))
|
||||
if (method === 'escape') fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
else if (method === 'backdrop') fireEvent.mouseDown(dialog.parentElement!)
|
||||
else fireEvent.click(within(dialog).getByRole('button', { name: method === 'cancel' ? '取消' : '关闭模型下载源' }))
|
||||
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
|
||||
expect(trigger).toHaveFocus()
|
||||
expect(updateApplicationSettings).not.toHaveBeenCalled()
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByRole('radio', { name: /ModelScope/u })).toBeChecked()
|
||||
})
|
||||
|
||||
it('locks the source dialog during confirmation and keeps the confirmed label until saved', async () => {
|
||||
let resolveSave!: (value: ApplicationSettings) => void
|
||||
updateApplicationSettings.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
|
||||
const onClose = vi.fn()
|
||||
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={onClose} onSaved={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
await screen.findByText('当前来源:ModelScope')
|
||||
fireEvent.click(screen.getByRole('button', { name: '模型下载源' }))
|
||||
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
|
||||
fireEvent.click(within(dialog).getByRole('radio', { name: /Hugging Face/u }))
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认' }))
|
||||
expect(within(dialog).getByRole('button', { name: '保存中…' })).toBeDisabled()
|
||||
expect(within(dialog).getByRole('button', { name: '取消' })).toBeDisabled()
|
||||
expect(within(dialog).getByRole('button', { name: '关闭模型下载源' })).toBeDisabled()
|
||||
expect(within(dialog).getByRole('radio', { name: /ModelScope/u })).toBeDisabled()
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
fireEvent.mouseDown(dialog.parentElement!)
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(updateApplicationSettings).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByText('当前来源:ModelScope')).toBeInTheDocument()
|
||||
await act(async () => resolveSave({ ...applicationSettings, modelDownloadSource: 'hugging-face' }))
|
||||
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('当前来源:Hugging Face')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('retries loading the source without guessing a selected radio', async () => {
|
||||
getApplicationSettings.mockRejectedValueOnce(new Error('read failed'))
|
||||
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
await screen.findByText('读取模型下载源失败,请重试')
|
||||
fireEvent.click(screen.getByRole('button', { name: '模型下载源' }))
|
||||
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
|
||||
expect(within(dialog).queryByRole('radio')).not.toBeInTheDocument()
|
||||
expect(within(dialog).getByRole('button', { name: '确认' })).toBeDisabled()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '重试' }))
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).getByRole('radio', { name: /ModelScope/u })).toBeChecked()
|
||||
expect(within(dialog).getByRole('button', { name: '确认' })).toBeEnabled()
|
||||
})
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认' }))
|
||||
expect(updateApplicationSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['embedding', 'speech'])('opens the shared source dialog from %s and updates download availability after confirm', async (kind) => {
|
||||
const downloadAvailability = [
|
||||
{ source: 'modelscope' as const, available: false, unavailableReason: '当前来源缺少完整文件' },
|
||||
{ source: 'hugging-face' as const, available: true, totalBytes: 1 }
|
||||
]
|
||||
getEmbeddingSnapshot.mockResolvedValueOnce({
|
||||
...embeddingSnapshot,
|
||||
models: { ...embeddingSnapshot.models, catalog: [{ ...embeddingCatalogEntry, downloadAvailability }] }
|
||||
})
|
||||
speechModelSnapshot = {
|
||||
...createSpeechModelSnapshot(null),
|
||||
installed: [],
|
||||
catalog: [{ ...speechCatalog[0]!, downloadAvailability }]
|
||||
}
|
||||
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
await screen.findByDisplayValue('默认模型')
|
||||
fireEvent.click(screen.getByRole('button', { name: kind === 'embedding' ? '向量模型' : '语音输入' }))
|
||||
if (kind === 'embedding') {
|
||||
fireEvent.click(await screen.findByRole('button', { name: '编辑向量模型连接 GoodBuddy 内置向量模型' }))
|
||||
}
|
||||
await screen.findByText('当前来源不可下载')
|
||||
const entry = screen.getAllByRole('button', { name: '模型下载源' }).find((button) => !button.closest('.model-type-navigation'))!
|
||||
entry.focus()
|
||||
fireEvent.click(entry)
|
||||
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
|
||||
expect(document.getElementById('settings-tab-model')).toHaveAttribute('aria-selected', 'true')
|
||||
fireEvent.click(within(dialog).getByRole('radio', { name: /Hugging Face/u }))
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认' }))
|
||||
const modelName = kind === 'embedding' ? embeddingCatalogEntry.displayName : speechCatalog[0]!.displayName
|
||||
const download = await screen.findByRole('button', { name: `下载 ${modelName}` })
|
||||
expect(screen.queryByText('当前来源不可下载')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '模型下载源' })).toHaveFocus()
|
||||
fireEvent.click(download)
|
||||
await waitFor(() => {
|
||||
if (kind === 'embedding') expect(installEmbeddingModel).toHaveBeenCalledWith(embeddingCatalogEntry.id, 'hugging-face')
|
||||
else expect(window.goodbuddy.speechModels!.install).toHaveBeenCalledWith(speechCatalog[0]!.id, 'hugging-face')
|
||||
})
|
||||
})
|
||||
|
||||
it.each([false, true])('opens the source dialog on the model page from OCR, respecting dirty draft = %s', async (dirty) => {
|
||||
const snapshot: DocumentParsingSnapshot = {
|
||||
settings: { chatWorkflow: 'auto', knowledgeWorkflow: 'complete-index', localOcrModelId: 'pp-ocrv6-tiny', maximumPages: 100, pageTimeoutSeconds: 60 },
|
||||
status: { nativeParsingAvailable: true, conversionAvailable: false, localOcr: {
|
||||
id: 'pp-ocrv6-tiny', displayName: 'PP-OCRv6 Tiny', available: false, verified: false, runtime: 'onnxruntime-web-wasm', detail: '模型尚未安装'
|
||||
} },
|
||||
ocrModels: {
|
||||
rootDirectory: 'C:\\models\\ocr', selectedDownloadSource: 'modelscope', installed: [], operations: [],
|
||||
catalog: [{
|
||||
id: 'pp-ocrv6-tiny', displayName: 'PP-OCRv6 Tiny', description: 'OCR', languages: ['中文'],
|
||||
runtime: 'onnxruntime-web-wasm', quality: 'basic', speed: 'fast', recommended: true,
|
||||
license: { name: 'Apache-2.0', notice: 'Apache-2.0', url: 'https://example.com/license' }, files: [],
|
||||
downloadAvailability: [{ source: 'modelscope', available: false, unavailableReason: '当前来源缺少完整文件' }]
|
||||
}]
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window.goodbuddy, 'documentParsing', {
|
||||
configurable: true,
|
||||
value: { getSnapshot: vi.fn(async () => snapshot), getOcrModelProgress: vi.fn(async () => ({ operations: [] })) }
|
||||
})
|
||||
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '文档解析' }))
|
||||
const entry = await screen.findByRole('button', { name: '模型下载源' })
|
||||
if (dirty) fireEvent.change(screen.getByRole('combobox', { name: '聊天与成果文件' }), { target: { value: 'fast-text' } })
|
||||
entry.focus()
|
||||
fireEvent.click(entry)
|
||||
if (dirty) {
|
||||
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
|
||||
expect(screen.getByRole('combobox', { name: '聊天与成果文件' })).toHaveValue('fast-text')
|
||||
fireEvent.click(entry)
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃更改并切换' }))
|
||||
}
|
||||
const dialog = await screen.findByRole('dialog', { name: '模型下载源' })
|
||||
expect(document.getElementById('settings-tab-model')).toHaveAttribute('aria-selected', 'true')
|
||||
expect(await within(dialog).findByRole('radio', { name: /ModelScope/u })).toBeChecked()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }))
|
||||
expect(screen.getByRole('button', { name: '模型下载源' })).toHaveFocus()
|
||||
expect(updateApplicationSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes built-in Notes MCP after enabling Magic Notes', async () => {
|
||||
|
||||
@@ -54,6 +54,8 @@ import { CapabilitiesAndToolsSettingsSection } from './CapabilitiesAndToolsSetti
|
||||
import { ChannelSettingsSection } from './ChannelSettingsSection'
|
||||
import { UpdateSettingsSection } from './UpdateSettingsSection'
|
||||
import { PlatformFeaturesSettingsSection } from './PlatformFeaturesSettingsSection'
|
||||
import { ModelDownloadSourceSettings } from './ModelDownloadSourceSettings'
|
||||
import type { ModelDownloadSource } from '../../shared/application-settings-contracts'
|
||||
import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
|
||||
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
|
||||
import { DshMarketplaceSection } from './DshMarketplaceSection'
|
||||
@@ -779,7 +781,7 @@ export function SettingsPanel({
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [embeddingSnapshot, setEmbeddingSnapshot] =
|
||||
useState<EmbeddingSettingsSnapshot>()
|
||||
const [embeddingModels, setEmbeddingModels] =
|
||||
const [embeddingModelSnapshot, setEmbeddingModels] =
|
||||
useState<EmbeddingModelSnapshot>()
|
||||
const [embeddingDiagnostic, setEmbeddingDiagnostic] =
|
||||
useState<EmbeddingDiagnosticResult>()
|
||||
@@ -808,6 +810,11 @@ export function SettingsPanel({
|
||||
? 'platform-features'
|
||||
: selectedTab
|
||||
const [modelType, setModelType] = useState<ModelType>('llm')
|
||||
const [modelDownloadSourceOpen, setModelDownloadSourceOpen] = useState(false)
|
||||
const [modelDownloadSource, setModelDownloadSource] = useState<ModelDownloadSource>()
|
||||
const embeddingModels = embeddingModelSnapshot && modelDownloadSource
|
||||
? { ...embeddingModelSnapshot, selectedDownloadSource: modelDownloadSource }
|
||||
: embeddingModelSnapshot
|
||||
const [speechModelDraftId, setSpeechModelDraftId] = useState<
|
||||
string | null | undefined
|
||||
>()
|
||||
@@ -828,7 +835,7 @@ export function SettingsPanel({
|
||||
const [sshHostsDirty, setSshHostsDirty] = useState(false)
|
||||
const [pendingLeave, setPendingLeave] = useState<
|
||||
| { kind: 'close' }
|
||||
| { kind: 'navigate'; category: SettingsCategoryId }
|
||||
| { kind: 'navigate'; category: SettingsCategoryId; openModelDownloadSource?: boolean }
|
||||
| { kind: 'external'; proceed: () => void }
|
||||
>()
|
||||
const runtimeCustomizationRef =
|
||||
@@ -1053,7 +1060,8 @@ export function SettingsPanel({
|
||||
|
||||
const requestTabChange = (
|
||||
category: SettingsCategoryId,
|
||||
trigger?: HTMLElement
|
||||
trigger?: HTMLElement,
|
||||
openModelDownloadSource = false
|
||||
): boolean => {
|
||||
if (category === activeTab) {
|
||||
return true
|
||||
@@ -1064,7 +1072,7 @@ export function SettingsPanel({
|
||||
(document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: undefined)
|
||||
setPendingLeave({ kind: 'navigate', category })
|
||||
setPendingLeave({ kind: 'navigate', category, openModelDownloadSource })
|
||||
return false
|
||||
}
|
||||
setPendingLeave(undefined)
|
||||
@@ -1073,6 +1081,10 @@ export function SettingsPanel({
|
||||
return true
|
||||
}
|
||||
|
||||
const openModelDownloadSource = (): void => {
|
||||
if (requestTabChange('model', undefined, true)) setModelDownloadSourceOpen(true)
|
||||
}
|
||||
|
||||
const visibleSettingsCategories = getSettingsCategoryList(
|
||||
remoteProjectsEnabled
|
||||
)
|
||||
@@ -1328,6 +1340,10 @@ export function SettingsPanel({
|
||||
setError(undefined)
|
||||
if (leave?.kind === 'navigate') {
|
||||
setActiveTab(leave.category)
|
||||
if (leave.openModelDownloadSource) {
|
||||
setModelDownloadSourceOpen(true)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() =>
|
||||
document
|
||||
.getElementById(`settings-tab-${leave.category}`)
|
||||
@@ -2285,7 +2301,7 @@ export function SettingsPanel({
|
||||
/>
|
||||
</div>
|
||||
) : activeTab === 'model' ? (
|
||||
<div className="model-type-navigation">
|
||||
<div className="model-type-navigation model-connections-navigation">
|
||||
<SegmentedControl
|
||||
ariaLabel={t('model.typeAriaLabel')}
|
||||
onChange={setModelType}
|
||||
@@ -2297,6 +2313,13 @@ export function SettingsPanel({
|
||||
]}
|
||||
value={modelType}
|
||||
/>
|
||||
<ModelDownloadSourceSettings
|
||||
open={modelDownloadSourceOpen}
|
||||
onSourceChanged={setModelDownloadSource}
|
||||
onOpen={openModelDownloadSource}
|
||||
onClose={() => setModelDownloadSourceOpen(false)}
|
||||
onNotify={onNotify}
|
||||
/>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
@@ -3769,9 +3792,7 @@ export function SettingsPanel({
|
||||
})
|
||||
)
|
||||
}}
|
||||
onOpenModelDownloadSourceSettings={() =>
|
||||
requestTabChange('platform-features')
|
||||
}
|
||||
onOpenModelDownloadSourceSettings={openModelDownloadSource}
|
||||
onSelectConnection={setEditingEmbeddingConnectionId}
|
||||
onSetCurrent={setActiveEmbeddingConnectionId}
|
||||
onTestConnection={(connectionId) => {
|
||||
@@ -3917,10 +3938,9 @@ export function SettingsPanel({
|
||||
)}
|
||||
{modelType === 'speech' && (
|
||||
<SpeechModelSettingsSection
|
||||
downloadSource={modelDownloadSource}
|
||||
onNotify={onNotify}
|
||||
onOpenModelDownloadSourceSettings={() =>
|
||||
requestTabChange('platform-features')
|
||||
}
|
||||
onOpenModelDownloadSourceSettings={openModelDownloadSource}
|
||||
onSelectedModelIdChange={(modelId, changed) => {
|
||||
setSpeechModelDraftId(modelId)
|
||||
setSpeechModelSelectionDirty(changed)
|
||||
@@ -4127,9 +4147,7 @@ export function SettingsPanel({
|
||||
onBusyChange={setTesting}
|
||||
onDirtyChange={setDocumentParsingDirty}
|
||||
onNotify={onNotify}
|
||||
onOpenModelDownloadSourceSettings={() =>
|
||||
requestTabChange('platform-features')
|
||||
}
|
||||
onOpenModelDownloadSourceSettings={openModelDownloadSource}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ describe('SpeechModelSettingsSection', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an unavailable source explicit and offers General settings', async () => {
|
||||
it('keeps an unavailable source explicit and offers source settings', async () => {
|
||||
await changeUiLocale('zh-CN')
|
||||
const onOpenModelDownloadSourceSettings = vi.fn()
|
||||
const unavailableSnapshot: SpeechModelSnapshot = {
|
||||
@@ -338,7 +338,7 @@ describe('SpeechModelSettingsSection', () => {
|
||||
})
|
||||
).toBeDisabled()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '前往通用设置' })
|
||||
screen.getByRole('button', { name: '模型下载源' })
|
||||
)
|
||||
expect(onOpenModelDownloadSourceSettings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -17,12 +17,14 @@ import type {
|
||||
SpeechModelSnapshot
|
||||
} from '../../shared/speech-model-contracts'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import type { ModelDownloadSource } from '../../shared/application-settings-contracts'
|
||||
import {
|
||||
formatModelPackageBytes,
|
||||
modelOperationPercent
|
||||
} from './model-download-presentation'
|
||||
|
||||
type SpeechModelSettingsSectionProps = {
|
||||
downloadSource?: ModelDownloadSource
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
persistedSelectedModelId?: string | null
|
||||
selectedModelId?: string | null
|
||||
@@ -64,6 +66,7 @@ function operationLabel(
|
||||
}
|
||||
|
||||
export function SpeechModelSettingsSection({
|
||||
downloadSource,
|
||||
onNotify,
|
||||
persistedSelectedModelId,
|
||||
selectedModelId,
|
||||
@@ -258,6 +261,7 @@ export function SpeechModelSettingsSection({
|
||||
)
|
||||
}
|
||||
|
||||
const selectedDownloadSource = downloadSource ?? snapshot.selectedDownloadSource
|
||||
const installedById = new Map(
|
||||
snapshot.installed.map((model) => [model.id, model])
|
||||
)
|
||||
@@ -299,7 +303,7 @@ export function SpeechModelSettingsSection({
|
||||
: undefined
|
||||
const downloadAvailability = model?.downloadAvailability.find(
|
||||
(availability) =>
|
||||
availability.source === snapshot.selectedDownloadSource
|
||||
availability.source === selectedDownloadSource
|
||||
)
|
||||
const size = installed
|
||||
? installed.files.reduce(
|
||||
@@ -386,7 +390,7 @@ export function SpeechModelSettingsSection({
|
||||
: entry.downloadAvailability.some(
|
||||
(availability) =>
|
||||
availability.source ===
|
||||
snapshot.selectedDownloadSource &&
|
||||
selectedDownloadSource &&
|
||||
availability.available
|
||||
)
|
||||
? t('speech.status.availableToDownload')
|
||||
@@ -421,7 +425,7 @@ export function SpeechModelSettingsSection({
|
||||
{
|
||||
name: displayName,
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
`modelDownloadSources.${selectedDownloadSource}`
|
||||
)
|
||||
}
|
||||
)}
|
||||
@@ -437,7 +441,7 @@ export function SpeechModelSettingsSection({
|
||||
{
|
||||
name: displayName,
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
`modelDownloadSources.${selectedDownloadSource}`
|
||||
)
|
||||
}
|
||||
)}
|
||||
@@ -450,7 +454,7 @@ export function SpeechModelSettingsSection({
|
||||
<div className="document-ocr-model__tags">
|
||||
<span className="speech-model-tag">
|
||||
{t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
`modelDownloadSources.${selectedDownloadSource}`
|
||||
)}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
@@ -577,7 +581,7 @@ export function SpeechModelSettingsSection({
|
||||
() =>
|
||||
window.goodbuddy.speechModels!.install(
|
||||
model.id,
|
||||
snapshot.selectedDownloadSource
|
||||
selectedDownloadSource
|
||||
),
|
||||
t('speech.notifications.installed', {
|
||||
name: displayName
|
||||
@@ -637,7 +641,7 @@ export function SpeechModelSettingsSection({
|
||||
<p className="settings-warning">
|
||||
{t('speech.sourceUnavailableDescription', {
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
`modelDownloadSources.${selectedDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('UpdateSettingsSection', () => {
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
|
||||
@@ -125,6 +125,12 @@ describe('WorkspacePrimitives', () => {
|
||||
expect(segmentedStyles).toBeDefined()
|
||||
expect(segmentedStyles).toMatch(/flex:\s*0 0 auto;/u)
|
||||
})
|
||||
it('keeps model download source on the model navigation row and aligned right', () => {
|
||||
expect(stylesheet).toMatch(/\.model-type-navigation\s*\{[^}]*flex:\s*0 0 auto;/u)
|
||||
expect(stylesheet).toMatch(/\.model-type-navigation\.model-connections-navigation\s*\{[^}]*flex-direction:\s*row;/u)
|
||||
expect(stylesheet).toMatch(/\.model-connections-navigation > \.segmented-control\s*\{[^}]*flex-shrink:\s*1;[^}]*min-width:\s*0;/u)
|
||||
expect(stylesheet).toMatch(/\.model-download-source-control\s*\{[^}]*flex:\s*0 0 auto;[^}]*margin-left:\s*auto;/u)
|
||||
})
|
||||
it('bounds knowledge library cards before truncating long names', () => {
|
||||
expect(stylesheet).toMatch(
|
||||
/\.knowledge-workspace__library-list\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\);/u
|
||||
|
||||
@@ -5,16 +5,33 @@ export type Conversation = Omit<ConversationListSnapshot, "messages"> & {
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
// Message arrays are replaced, never mutated, so a conversation whose messages
|
||||
// did not change (every conversation except the streaming one) reuses its scan.
|
||||
const latestMessageTimes = new WeakMap<readonly Message[], number | undefined>();
|
||||
|
||||
function latestMessageTime(messages: readonly Message[]): number | undefined {
|
||||
if (latestMessageTimes.has(messages)) {
|
||||
return latestMessageTimes.get(messages);
|
||||
}
|
||||
const latest = messages.reduce<number | undefined>(
|
||||
(current, message) => Math.max(current ?? message.createdAt, message.createdAt),
|
||||
undefined,
|
||||
);
|
||||
latestMessageTimes.set(messages, latest);
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function sortConversationsForDisplay(conversations: readonly Conversation[]): Conversation[] {
|
||||
// updatedAt tracks snapshot freshness, including every streaming delta.
|
||||
// Compute stable message times once per conversation, not per comparison.
|
||||
return conversations.map(conversation => ({
|
||||
conversation,
|
||||
time: conversation.messages.reduce<number | undefined>(
|
||||
(latest, message) => Math.max(latest ?? message.createdAt, message.createdAt),
|
||||
conversation.messageSummary?.latestMessageAt,
|
||||
) ?? conversation.updatedAt,
|
||||
})).sort((left, right) =>
|
||||
return conversations.map(conversation => {
|
||||
const summaryTime = conversation.messageSummary?.latestMessageAt;
|
||||
const messageTime = latestMessageTime(conversation.messages);
|
||||
const latest = summaryTime === undefined
|
||||
? messageTime
|
||||
: messageTime === undefined ? summaryTime : Math.max(summaryTime, messageTime);
|
||||
return { conversation, time: latest ?? conversation.updatedAt };
|
||||
}).sort((left, right) =>
|
||||
Number(Boolean(right.conversation.pinned)) - Number(Boolean(left.conversation.pinned)) ||
|
||||
right.time - left.time || left.conversation.id.localeCompare(right.conversation.id),
|
||||
).map(item => item.conversation);
|
||||
|
||||
@@ -78,3 +78,22 @@ describe('deriveConversationActivity', () => {
|
||||
expect(summary.activities.map(row => row.status)).toEqual(['completed', 'running', 'completed'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('conversation activity reuse', () => {
|
||||
it('reflects replaced message arrays and compares summaries by content', async () => {
|
||||
const { sameActivitySummary } = await import('./conversation-activity')
|
||||
const messages = [{ state: 'complete' }]
|
||||
const conversation = { id: 'a', title: 'A', projectId: 'local', messages }
|
||||
const first = deriveConversationActivity([conversation], [], new Set(), projects, 'Unknown')
|
||||
expect(first.activities).toEqual([])
|
||||
// A new message array (the immutable update path) is rescanned.
|
||||
const streaming = { ...conversation, messages: [...messages, { state: 'streaming' }] }
|
||||
const second = deriveConversationActivity([streaming], [], new Set(), projects, 'Unknown')
|
||||
expect(second.activities.map(({ status }) => status)).toEqual(['running'])
|
||||
const third = deriveConversationActivity([{ ...streaming, messages: [...streaming.messages] }], [], new Set(), projects, 'Unknown')
|
||||
expect(sameActivitySummary(second, third)).toBe(true)
|
||||
expect(sameActivitySummary(first, second)).toBe(false)
|
||||
const renamed = deriveConversationActivity([{ ...streaming, title: 'B' }], [], new Set(), projects, 'Unknown')
|
||||
expect(sameActivitySummary(second, renamed)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,51 @@ export type ConversationActivitySummary = {
|
||||
completed: number
|
||||
}
|
||||
|
||||
type ActivityMessage = { state?: string; approval?: unknown; pendingQuestions?: readonly unknown[] }
|
||||
type MessageStatus = 'running' | 'approval' | 'question' | undefined
|
||||
const statusPriority = { completed: -1, running: 0, attention: 1, approval: 2, question: 3 }
|
||||
|
||||
// Message arrays are replaced rather than mutated, so unchanged conversations
|
||||
// reuse their scan while one conversation streams.
|
||||
const messageStatuses = new WeakMap<readonly ActivityMessage[], MessageStatus>()
|
||||
|
||||
function highestMessageStatus(messages: readonly ActivityMessage[]): MessageStatus {
|
||||
if (messageStatuses.has(messages)) return messageStatuses.get(messages)
|
||||
let status: MessageStatus
|
||||
for (const message of messages) {
|
||||
const candidate = message.pendingQuestions?.length
|
||||
? 'question'
|
||||
: message.approval
|
||||
? 'approval'
|
||||
: message.state === 'streaming'
|
||||
? 'running'
|
||||
: undefined
|
||||
if (candidate && (!status || statusPriority[candidate] > statusPriority[status])) {
|
||||
status = candidate
|
||||
}
|
||||
}
|
||||
messageStatuses.set(messages, status)
|
||||
return status
|
||||
}
|
||||
|
||||
export function sameActivitySummary(left: ConversationActivitySummary, right: ConversationActivitySummary): boolean {
|
||||
if (left.running !== right.running || left.attention !== right.attention ||
|
||||
left.completed !== right.completed || left.activities.length !== right.activities.length) return false
|
||||
for (let index = 0; index < left.activities.length; index++) {
|
||||
const a = left.activities[index]!
|
||||
const b = right.activities[index]!
|
||||
if (a.conversationId !== b.conversationId || a.projectId !== b.projectId || a.title !== b.title ||
|
||||
a.projectName !== b.projectName || a.status !== b.status) return false
|
||||
}
|
||||
const leftProjects = Object.keys(left.byProjectId)
|
||||
if (leftProjects.length !== Object.keys(right.byProjectId).length) return false
|
||||
return leftProjects.every((projectId) => {
|
||||
const a = left.byProjectId[projectId]!
|
||||
const b = right.byProjectId[projectId]
|
||||
return b !== undefined && a.running === b.running && a.attention === b.attention && a.completed === b.completed
|
||||
})
|
||||
}
|
||||
|
||||
export function deriveConversationActivity(
|
||||
conversations: readonly {
|
||||
id: string
|
||||
@@ -35,23 +80,15 @@ export function deriveConversationActivity(
|
||||
const projectNames = new Map(projects.map((project) => [project.id, project.name]))
|
||||
const metadata = new Map(conversations.map((conversation) => [conversation.id, conversation]))
|
||||
const rows = new Map<string, ConversationActivity>()
|
||||
const priority = { completed: -1, running: 0, attention: 1, approval: 2, question: 3 }
|
||||
const priority = statusPriority
|
||||
|
||||
for (const conversation of conversations) {
|
||||
let status: ConversationActivity['status'] | undefined =
|
||||
activeConversationIds.has(conversation.id) ? 'running'
|
||||
: completedConversationIds.has(conversation.id) ? 'completed' : undefined
|
||||
for (const message of conversation.messages) {
|
||||
const candidate = message.pendingQuestions?.length
|
||||
? 'question'
|
||||
: message.approval
|
||||
? 'approval'
|
||||
: message.state === 'streaming'
|
||||
? 'running'
|
||||
: undefined
|
||||
if (candidate && (!status || priority[candidate] > priority[status])) {
|
||||
status = candidate
|
||||
}
|
||||
const candidate = highestMessageStatus(conversation.messages)
|
||||
if (candidate && (!status || priority[candidate] > priority[status])) {
|
||||
status = candidate
|
||||
}
|
||||
if (status) {
|
||||
rows.set(conversation.id, {
|
||||
|
||||
@@ -7,6 +7,11 @@ export const activity = {
|
||||
description:
|
||||
'Review execution details by project, task, and conversation, or browse the activity timeline and model usage.'
|
||||
},
|
||||
refresh: {
|
||||
label: 'Refresh',
|
||||
refreshing: 'Refreshing',
|
||||
hint: 'Run history updates every 15 seconds; click to refresh now'
|
||||
},
|
||||
tabs: {
|
||||
ariaLabel: 'Run history views',
|
||||
tasks: 'Tasks and conversations',
|
||||
@@ -72,9 +77,6 @@ export const activity = {
|
||||
},
|
||||
detailAriaLabel: 'Token usage details by {{group}}',
|
||||
empty: 'No token usage',
|
||||
refresh: 'Refresh',
|
||||
refreshing: 'Refreshing',
|
||||
refreshHint: 'Reload saved token usage',
|
||||
fallbacks: {
|
||||
unassignedProject: 'Unassigned project',
|
||||
deletedConversation: 'Deleted conversation',
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { app as chineseApp } from '../zh-CN/app'
|
||||
|
||||
export const app = {
|
||||
sharing: {
|
||||
title: 'Device Sharing', boundary: 'Local unauthenticated metadata service. Device IDs identify records, not authenticated owners; registration does not indicate online status. Remote execution and knowledge content access are not provided here.',
|
||||
title: 'Device Sharing (Technical Preview)', boundary: 'Local unauthenticated metadata service. Device IDs identify records, not authenticated owners; registration does not indicate online status. Remote execution and knowledge content access are not provided here.',
|
||||
refresh: 'Refresh lists', working: 'Working…', loadFailed: 'Could not load the lists. Previous results may be stale. Check the service URL and refresh.',
|
||||
thisDevice: 'This device', deviceId: 'Device ID', serverUrl: 'Service URL', deviceName: 'Device name', save: 'Save settings', saved: 'Sharing settings saved',
|
||||
register: 'Register this device', registered: 'Device registration saved to the service', saveFirst: 'Save settings before registering or publishing.',
|
||||
@@ -29,13 +29,14 @@ export const app = {
|
||||
localInference: 'Local Inference Monitor', settings: 'Application settings', enable: 'Enable application', pin: 'Pin to sidebar',
|
||||
pinHelp: 'Pinning only controls the sidebar shortcut.',
|
||||
disableHelp: 'Disabling preserves data and pins without stopping existing requests or background services.',
|
||||
deviceSharingHelp: 'Off by default during testing. Enable to open the page and register devices or publish metadata to the configured ShareServer. Disabling hides the entry and leaves the page without clearing the catalog, revoking publications, or stopping existing requests.',
|
||||
disabledPage: 'Application is disabled', close: 'Close application center', retry: 'Reload settings', back: 'Back to application center',
|
||||
search: 'Search built-in applications', noResults: 'No matching applications', clear: 'Clear search', builtin: 'Built-in',
|
||||
enabled: 'Enabled', disabled: 'Disabled', open: 'Open', moveUp: 'Move {{name}} up', moveDown: 'Move {{name}} down',
|
||||
orderUpdated: 'Application order updated', loadFailed: 'Could not load application settings', saveFailed: 'Could not save application settings',
|
||||
confirmFailed: 'Could not confirm the save result. Reload settings before editing again.',
|
||||
descriptions: {
|
||||
'device-sharing': 'Register devices, publish and revoke capability or knowledge metadata.',
|
||||
'device-sharing': 'Test feature, off by default. Register devices, publish and revoke capability or knowledge metadata.',
|
||||
'magic-notes': 'Write notes, organize to-dos, and receive AI comments.', knowledge: 'Manage knowledge sources and retrieve reference material for conversations.',
|
||||
heartbeat: 'Review work evolution, explore the story graph, and configure Smart heartbeat.', 'local-inference': 'Inspect local inference tasks and execution services.'
|
||||
}
|
||||
|
||||
@@ -636,7 +636,7 @@ export const settings = {
|
||||
confirmDelete: 'Confirm delete',
|
||||
cancel: 'Cancel',
|
||||
openRepository: 'Open {{source}}',
|
||||
openDownloadSourceSettings: 'Open General settings',
|
||||
openDownloadSourceSettings: 'Model download source',
|
||||
catalogUnavailable:
|
||||
'No OCR model catalog is available in this version.',
|
||||
selectedModelUnavailable:
|
||||
@@ -644,7 +644,7 @@ export const settings = {
|
||||
installBeforeSelecting:
|
||||
'Download or import this model first. It will become the current model after installation.',
|
||||
sourceUnavailableDescription:
|
||||
'{{source}} does not currently provide the complete verified files for this model. You can still import a ZIP archive or explicitly change the source in General settings.',
|
||||
'{{source}} does not currently provide the complete verified files for this model. You can still import a ZIP archive or click Model download source to change sources.',
|
||||
privacyNotice:
|
||||
'OCR is enabled only when required by the scenario modes above. It always runs locally through ONNX Runtime WebAssembly and never uploads documents.',
|
||||
operations: {
|
||||
|
||||
@@ -300,7 +300,7 @@ export const settingsSections = {
|
||||
delete: 'Delete',
|
||||
confirmDelete: 'Confirm delete',
|
||||
download: 'Download',
|
||||
openDownloadSourceSettings: 'Open General settings',
|
||||
openDownloadSourceSettings: 'Model download source',
|
||||
importZip: 'Import ZIP',
|
||||
exportZip: 'Export ZIP'
|
||||
},
|
||||
@@ -320,7 +320,7 @@ export const settingsSections = {
|
||||
removed: 'Speech model deleted'
|
||||
},
|
||||
sourceUnavailableDescription:
|
||||
'{{source}} does not currently provide the complete verified files for this model. You can still import a ZIP archive or explicitly change the source in General settings.',
|
||||
'{{source}} does not currently provide the complete verified files for this model. You can still import a ZIP archive or click Model download source to change sources.',
|
||||
languages: {
|
||||
中文: 'Chinese',
|
||||
粤语: 'Cantonese',
|
||||
@@ -393,7 +393,7 @@ export const settingsSections = {
|
||||
download: 'Download',
|
||||
cancel: 'Cancel',
|
||||
importZip: 'Import ZIP',
|
||||
openDownloadSourceSettings: 'Go to General settings',
|
||||
openDownloadSourceSettings: 'Model download source',
|
||||
remove: 'Remove model',
|
||||
clearCredential: 'Clear credential',
|
||||
clearAfterSave: 'Credential will be cleared on save'
|
||||
@@ -564,7 +564,7 @@ export const settingsSections = {
|
||||
},
|
||||
groups: {
|
||||
conversation: 'Conversations & notifications',
|
||||
workspace: 'Workspace & downloads'
|
||||
workspace: 'Workspace'
|
||||
},
|
||||
desktopNotifications: {
|
||||
enabled: 'Desktop notifications',
|
||||
@@ -678,6 +678,11 @@ export const settingsSections = {
|
||||
},
|
||||
modelDownloadSource: {
|
||||
title: 'Model download source',
|
||||
current: 'Current source: {{source}}',
|
||||
readFailed: 'Could not load the model download source. Try again.',
|
||||
retry: 'Retry',
|
||||
close: 'Close model download source',
|
||||
confirm: 'Confirm',
|
||||
description:
|
||||
'Choose the platform for future GoodBuddy-managed local model downloads. Installed models, ZIP imports, Ollama models, and app updates are not affected.',
|
||||
options: {
|
||||
|
||||
@@ -4,6 +4,11 @@ export const activity = {
|
||||
description:
|
||||
'按项目、任务与会话查看执行详情,或浏览活动时间线和模型用量。'
|
||||
},
|
||||
refresh: {
|
||||
label: '刷新',
|
||||
refreshing: '正在刷新',
|
||||
hint: '运行记录每 15 秒自动更新,点击立即刷新'
|
||||
},
|
||||
tabs: {
|
||||
ariaLabel: '运行记录视图',
|
||||
tasks: '任务与会话',
|
||||
@@ -69,9 +74,6 @@ export const activity = {
|
||||
},
|
||||
detailAriaLabel: 'Token 用量{{group}}明细',
|
||||
empty: '暂无 Token 用量',
|
||||
refresh: '刷新',
|
||||
refreshing: '正在刷新',
|
||||
refreshHint: '重新读取已保存的 Token 用量',
|
||||
fallbacks: {
|
||||
unassignedProject: '未归属项目',
|
||||
deletedConversation: '已删除会话',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const app = {
|
||||
sharing: {
|
||||
title: '设备共享', boundary: '本地未认证的元数据服务。设备 ID 仅用于识别,不代表身份认证;注册记录不代表设备在线。此页不提供远程执行或知识内容访问。',
|
||||
title: '设备共享(技术预览)', boundary: '本地未认证的元数据服务。设备 ID 仅用于识别,不代表身份认证;注册记录不代表设备在线。此页不提供远程执行或知识内容访问。',
|
||||
refresh: '刷新列表', working: '正在处理…', loadFailed: '列表读取失败,已有列表可能过期。请检查服务地址并刷新。',
|
||||
thisDevice: '本机', deviceId: '设备 ID', serverUrl: '服务地址', deviceName: '设备名称', save: '保存设置', saved: '共享设置已保存',
|
||||
register: '注册本机', registered: '本机注册信息已保存到服务', saveFirst: '请先保存设置,再注册或发布。',
|
||||
@@ -26,13 +26,14 @@ export const app = {
|
||||
localInference: '本机推理监控', settings: '应用设置', enable: '启用应用', pin: '常驻左侧菜单',
|
||||
pinHelp: '常驻只控制左侧捷径。',
|
||||
disableHelp: '关闭应用保留数据和常驻偏好,不停止已有请求或后台服务。',
|
||||
deviceSharingHelp: '测试阶段默认关闭。启用后可打开页面,向配置的 ShareServer 注册设备和发布元数据。关闭只隐藏入口并退出页面,不清除目录或撤销已发布记录,也不停止已有请求。',
|
||||
disabledPage: '应用已关闭', close: '关闭应用中心', retry: '重新读取', back: '返回应用中心',
|
||||
search: '搜索内置应用', noResults: '未找到相关应用', clear: '清除搜索', builtin: '内置',
|
||||
enabled: '已启用', disabled: '已关闭', open: '打开', moveUp: '上移 {{name}}', moveDown: '下移 {{name}}',
|
||||
orderUpdated: '应用顺序已更新', loadFailed: '无法读取应用设置', saveFailed: '应用设置保存失败',
|
||||
confirmFailed: '无法确认保存结果,请重新读取后再修改。',
|
||||
descriptions: {
|
||||
'device-sharing': '注册设备,发布和撤销能力或知识的元数据。',
|
||||
'device-sharing': '测试功能,默认关闭。注册设备,发布和撤销能力或知识的元数据。',
|
||||
'magic-notes': '记录笔记、整理待办并获取 AI 评论。', knowledge: '管理知识来源,为会话检索资料。',
|
||||
heartbeat: '回顾工作演变、查看故事线图谱,并配置智能心跳。', 'local-inference': '查看本机推理任务与执行服务。'
|
||||
}
|
||||
|
||||
@@ -587,14 +587,14 @@ export const settings = {
|
||||
confirmDelete: '确认删除',
|
||||
cancel: '取消',
|
||||
openRepository: '打开 {{source}}',
|
||||
openDownloadSourceSettings: '前往通用设置',
|
||||
openDownloadSourceSettings: '模型下载源',
|
||||
catalogUnavailable: '当前版本没有可用的 OCR 模型目录。',
|
||||
selectedModelUnavailable:
|
||||
'已保存的 OCR 模型在当前版本不可用,请从上方选择并安装其他模型。',
|
||||
installBeforeSelecting:
|
||||
'请先下载或导入该模型;安装完成后会自动设为当前模型。',
|
||||
sourceUnavailableDescription:
|
||||
'{{source}} 暂不提供此模型的完整已验证文件。你仍可从 ZIP 导入,或前往通用设置明确更换下载源。',
|
||||
'{{source}} 暂不提供此模型的完整已验证文件。你仍可从 ZIP 导入,或点击“模型下载源”更换来源。',
|
||||
privacyNotice:
|
||||
'OCR 只在需要时由上方场景模式启用,并始终在本机通过 ONNX Runtime WebAssembly 运行,不会上传文档。',
|
||||
operations: {
|
||||
|
||||
@@ -284,7 +284,7 @@ export const settingsSections = {
|
||||
delete: '删除',
|
||||
confirmDelete: '确认删除',
|
||||
download: '下载',
|
||||
openDownloadSourceSettings: '前往通用设置',
|
||||
openDownloadSourceSettings: '模型下载源',
|
||||
importZip: '导入 ZIP',
|
||||
exportZip: '导出 ZIP'
|
||||
},
|
||||
@@ -304,7 +304,7 @@ export const settingsSections = {
|
||||
removed: '语音模型已删除'
|
||||
},
|
||||
sourceUnavailableDescription:
|
||||
'{{source}} 暂不提供此模型的完整已验证文件。你仍可从 ZIP 导入,或前往通用设置明确更换下载源。',
|
||||
'{{source}} 暂不提供此模型的完整已验证文件。你仍可从 ZIP 导入,或点击“模型下载源”更换来源。',
|
||||
languages: {
|
||||
中文: '中文',
|
||||
粤语: '粤语',
|
||||
@@ -377,7 +377,7 @@ export const settingsSections = {
|
||||
download: '下载',
|
||||
cancel: '取消',
|
||||
importZip: '导入 ZIP',
|
||||
openDownloadSourceSettings: '前往通用设置',
|
||||
openDownloadSourceSettings: '模型下载源',
|
||||
remove: '移除模型',
|
||||
clearCredential: '清除凭据',
|
||||
clearAfterSave: '保存后清除凭据'
|
||||
@@ -534,7 +534,7 @@ export const settingsSections = {
|
||||
},
|
||||
groups: {
|
||||
conversation: '会话与通知',
|
||||
workspace: '工作目录与下载'
|
||||
workspace: '工作目录'
|
||||
},
|
||||
desktopNotifications: {
|
||||
enabled: '桌面通知',
|
||||
@@ -634,6 +634,11 @@ export const settingsSections = {
|
||||
},
|
||||
modelDownloadSource: {
|
||||
title: '模型下载源',
|
||||
current: '当前来源:{{source}}',
|
||||
readFailed: '读取模型下载源失败,请重试',
|
||||
retry: '重试',
|
||||
close: '关闭模型下载源',
|
||||
confirm: '确认',
|
||||
description:
|
||||
'选择 GoodBuddy 托管本地模型后续下载使用的平台。已安装模型、ZIP 导入、Ollama 模型和应用更新不受影响。',
|
||||
options: {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sortConversationsForDisplay, type Conversation } from './chat-conversation'
|
||||
import {
|
||||
sameArrayItems,
|
||||
sameMapEntries,
|
||||
useStableDerivedValue,
|
||||
useStableHandlers
|
||||
} from './stable-derived-value'
|
||||
|
||||
describe('useStableDerivedValue', () => {
|
||||
it('keeps the previous value only while it is equal', () => {
|
||||
const seen: Map<string, string>[] = []
|
||||
let setEntries!: (entries: [string, string][]) => void
|
||||
function Probe(): null {
|
||||
const [entries, set] = useState<[string, string][]>([['a', 'A']])
|
||||
setEntries = set
|
||||
seen.push(useStableDerivedValue(new Map(entries), sameMapEntries))
|
||||
return null
|
||||
}
|
||||
render(<Probe />)
|
||||
act(() => setEntries([['a', 'A']]))
|
||||
expect(seen[1]).toBe(seen[0])
|
||||
act(() => setEntries([['a', 'B']]))
|
||||
expect(seen[2]).not.toBe(seen[1])
|
||||
expect(seen[2]!.get('a')).toBe('B')
|
||||
})
|
||||
|
||||
it('compares arrays item by item', () => {
|
||||
expect(sameArrayItems([1, 2], [1, 2])).toBe(true)
|
||||
expect(sameArrayItems([1, 2], [1])).toBe(false)
|
||||
expect(sameArrayItems([{ id: 1 }], [{ id: 1 }], (a, b) => a.id === b.id)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useStableHandlers', () => {
|
||||
it('keeps one identity and always calls the latest implementation', () => {
|
||||
const results: string[] = []
|
||||
let handlersSeen: { run: () => void }[] = []
|
||||
let setLabel!: (label: string) => void
|
||||
function Probe(): null {
|
||||
const [label, set] = useState('first')
|
||||
setLabel = set
|
||||
handlersSeen.push(useStableHandlers({ run: () => results.push(label) }))
|
||||
return null
|
||||
}
|
||||
render(<Probe />)
|
||||
handlersSeen[0]!.run()
|
||||
act(() => setLabel('second'))
|
||||
handlersSeen[1]!.run()
|
||||
expect(handlersSeen[1]).toBe(handlersSeen[0])
|
||||
expect(results).toEqual(['first', 'second'])
|
||||
handlersSeen = []
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortConversationsForDisplay', () => {
|
||||
const message = (id: string, createdAt: number): Conversation['messages'][number] =>
|
||||
({ id, role: 'user', content: id, createdAt, state: 'complete' })
|
||||
|
||||
it('reflects new message arrays and summaries while reusing unchanged scans', () => {
|
||||
const a: Conversation = { id: 'a', title: 'A', updatedAt: 1, messages: [message('a1', 10)] }
|
||||
const b: Conversation = { id: 'b', title: 'B', updatedAt: 1, messages: [message('b1', 20)] }
|
||||
expect(sortConversationsForDisplay([a, b]).map(({ id }) => id)).toEqual(['b', 'a'])
|
||||
const newer = { ...a, messages: [...a.messages, message('a2', 30)] }
|
||||
expect(sortConversationsForDisplay([newer, b]).map(({ id }) => id)).toEqual(['a', 'b'])
|
||||
const summarized = { ...b, messageSummary: { count: 2, latestMessageAt: 40 } } as Conversation
|
||||
expect(sortConversationsForDisplay([newer, summarized]).map(({ id }) => id)).toEqual(['b', 'a'])
|
||||
const pinned = { ...a, pinned: true }
|
||||
expect(sortConversationsForDisplay([summarized, pinned]).map(({ id }) => id)).toEqual(['a', 'b'])
|
||||
const empty: Conversation = { id: 'c', title: 'C', updatedAt: 50, messages: [] }
|
||||
expect(sortConversationsForDisplay([newer, empty]).map(({ id }) => id)).toEqual(['c', 'a'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useLayoutEffect, useState } from 'react'
|
||||
|
||||
type AnyHandler = (...args: never[]) => unknown
|
||||
|
||||
/**
|
||||
* Returns an object of handlers whose identity never changes while each call
|
||||
* still runs the latest implementation (published after every commit).
|
||||
* Memoized children receiving it skip re-renders caused by new closures.
|
||||
* Calls are only valid after mount, as for any event handler.
|
||||
*/
|
||||
export function useStableHandlers<T extends Record<string, AnyHandler>>(
|
||||
latest: T
|
||||
): T {
|
||||
const [{ slot, handlers }] = useState(() => createForwardingHandlers(latest))
|
||||
useLayoutEffect(() => {
|
||||
latestHandlers.set(slot, latest)
|
||||
})
|
||||
return handlers
|
||||
}
|
||||
|
||||
const latestHandlers = new WeakMap<object, Record<string, AnyHandler>>()
|
||||
|
||||
function createForwardingHandlers<T extends Record<string, AnyHandler>>(
|
||||
initial: T
|
||||
): { slot: object; handlers: T } {
|
||||
const slot = {}
|
||||
latestHandlers.set(slot, initial)
|
||||
const handlers = {} as Record<string, AnyHandler>
|
||||
for (const key of Object.keys(initial)) {
|
||||
// Looked up when a handler is invoked (an event), never during render.
|
||||
handlers[key] = (...args: never[]) => latestHandlers.get(slot)![key]!(...args)
|
||||
}
|
||||
return { slot, handlers: handlers as T }
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the previous derived value when a recomputation produces an equal one.
|
||||
*
|
||||
* Streaming updates replace the conversation array on every delta, so values
|
||||
* derived from it (titles, pending approvals, activity summaries) would get a
|
||||
* new identity each time and re-render memoized consumers even though nothing
|
||||
* they display changed. Returning the previous, equal value is always safe.
|
||||
*/
|
||||
const previousValues = new WeakMap<object, unknown>()
|
||||
|
||||
export function useStableDerivedValue<T>(
|
||||
next: T,
|
||||
equal: (previous: T, next: T) => boolean
|
||||
): T {
|
||||
const [slot] = useState(() => ({}))
|
||||
if (previousValues.has(slot)) {
|
||||
const previous = previousValues.get(slot) as T
|
||||
if (previous === next || equal(previous, next)) {
|
||||
return previous
|
||||
}
|
||||
}
|
||||
previousValues.set(slot, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export function sameMapEntries<K, V>(
|
||||
left: ReadonlyMap<K, V>,
|
||||
right: ReadonlyMap<K, V>
|
||||
): boolean {
|
||||
if (left.size !== right.size) return false
|
||||
for (const [key, value] of left) {
|
||||
if (!right.has(key) || !Object.is(right.get(key), value)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function sameArrayItems<T>(
|
||||
left: readonly T[],
|
||||
right: readonly T[],
|
||||
sameItem: (left: T, right: T) => boolean = Object.is
|
||||
): boolean {
|
||||
if (left.length !== right.length) return false
|
||||
for (let index = 0; index < left.length; index++) {
|
||||
if (!sameItem(left[index]!, right[index]!)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -8794,7 +8794,7 @@ button > svg {
|
||||
min-height: 26px;
|
||||
margin: var(--space-1) 0 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
justify-content: flex-start;
|
||||
font-size: var(--font-caption);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
@@ -8806,7 +8806,6 @@ button > svg {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-right: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -8824,7 +8823,7 @@ button > svg {
|
||||
}
|
||||
|
||||
.composer-meta__error {
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.composer-meta__error {
|
||||
@@ -8866,7 +8865,6 @@ button > svg {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
margin-right: auto;
|
||||
color: var(--text-muted);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@@ -10207,6 +10205,29 @@ button > svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.model-type-navigation.model-connections-navigation {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.model-connections-navigation > .segmented-control {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.model-download-source-control {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.model-download-source-control > small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.model-type-navigation > small {
|
||||
flex: 1 1 220px;
|
||||
min-width: 0;
|
||||
@@ -15880,7 +15901,18 @@ textarea.field-control {
|
||||
font-size: var(--font-section-title);
|
||||
}
|
||||
|
||||
.token-usage__refresh {
|
||||
/* Keep the full title and its help visible; wrap the refresh action instead. */
|
||||
.activity-panel > .page-header {
|
||||
flex-wrap: wrap;
|
||||
row-gap: var(--space-3);
|
||||
}
|
||||
|
||||
.activity-panel > .page-header > .page-header__content {
|
||||
flex: 1 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.activity-panel__refresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
@@ -39,7 +39,7 @@ export const supervisionTimeoutSecondsSchema = z.number().int().min(30).max(600)
|
||||
|
||||
export const builtInApplicationIds = ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing'] as const
|
||||
export type BuiltInApplicationId = typeof builtInApplicationIds[number]
|
||||
export const editableApplicationIds = ['magic-notes', 'heartbeat', 'local-inference'] as const
|
||||
export const editableApplicationIds = ['magic-notes', 'heartbeat', 'local-inference', 'device-sharing'] as const
|
||||
export type EditableApplicationId = typeof editableApplicationIds[number]
|
||||
export const defaultApplicationNavigation = {
|
||||
order: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference', 'device-sharing'] as BuiltInApplicationId[],
|
||||
@@ -67,6 +67,7 @@ const applicationPreferencesSchema = z
|
||||
remoteProjectsEnabled: z.boolean(),
|
||||
applicationNavigation: applicationNavigationSchema.default(defaultApplicationNavigation),
|
||||
localInferenceEnabled: z.boolean().default(true),
|
||||
deviceSharingEnabled: z.boolean().default(false),
|
||||
magicNotesEnabled: z.boolean().default(true),
|
||||
heartbeatEnabled: z.boolean().optional(),
|
||||
heartbeatReportTimeoutSeconds: supervisionTimeoutSecondsSchema.optional(),
|
||||
@@ -93,6 +94,7 @@ export const applicationSettingsUpdateSchema = applicationPreferencesSchema
|
||||
desktopNotificationsEnabled: z.boolean().optional(),
|
||||
transparentFrostedEffectEnabled: z.boolean().optional(),
|
||||
localInferenceEnabled: z.boolean().optional(),
|
||||
deviceSharingEnabled: z.boolean().optional(),
|
||||
magicNotesEnabled: z.boolean().optional(),
|
||||
heartbeatEnabled: z.boolean().optional(),
|
||||
magicNotesShowIncompleteTodoCount: z.boolean().optional(),
|
||||
|
||||
@@ -159,6 +159,7 @@ describe('GoodBuddy configuration contracts', () => {
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation,
|
||||
localInferenceEnabled: true,
|
||||
deviceSharingEnabled: false,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { build as bundle } from 'esbuild'
|
||||
import { build } from 'vite'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
it('benchmarks native input during main-driven streaming at 16/32/50/80ms', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-streaming-input-'))
|
||||
try {
|
||||
await build({
|
||||
configFile: false, root: resolve('tests/support'), base: './',
|
||||
plugins: [react()], logLevel: 'error',
|
||||
build: { outDir: join(directory, 'renderer'), emptyOutDir: true,
|
||||
rollupOptions: { input: resolve('tests/support/streaming-input-page.html') } }
|
||||
})
|
||||
await bundle({ entryPoints: [resolve('tests/support/streaming-input-driver.ts')],
|
||||
outfile: join(directory, 'driver.mjs'), bundle: true, platform: 'node', format: 'esm',
|
||||
external: ['electron'] })
|
||||
await bundle({ entryPoints: [resolve('tests/support/streaming-input-preload.ts')],
|
||||
outfile: join(directory, 'preload.cjs'), bundle: true, platform: 'node', format: 'cjs',
|
||||
external: ['electron'] })
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, GOODBUDDY_STREAMING_INPUT_DIRECTORY: directory }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const child = spawn(createRequire(import.meta.url)('electron'), [join(directory, 'driver.mjs')],
|
||||
{ env, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''
|
||||
child.stdout.on('data', data => { output += data })
|
||||
child.stderr.on('data', data => { output += data })
|
||||
const timeout = setTimeout(() => child.kill(), 100_000)
|
||||
try {
|
||||
const code = await new Promise((resolve, reject) => {
|
||||
child.once('exit', resolve)
|
||||
child.once('error', reject)
|
||||
})
|
||||
process.stdout.write(output.split(/\r?\n/u).filter(line => line.startsWith('Streaming input ')).join('\n') + '\n')
|
||||
expect(code, output).toBe(0)
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
if (child.exitCode === null) child.kill()
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}, 120_000)
|
||||
@@ -35,6 +35,14 @@ void app.whenReady().then(async () => {
|
||||
try {
|
||||
await win.loadURL(process.env.GB_SHARING_RENDERER!)
|
||||
await wait(`!![...document.querySelectorAll('article')].find(e => e.textContent.includes('Device Sharing'))`)
|
||||
assert.equal(await js(`[...document.querySelectorAll('article')].find(e=>e.textContent.includes('Device Sharing')).querySelector('button').disabled`), true)
|
||||
await js(`document.querySelector('[aria-label="Device Sharing (Technical Preview) Application settings"]').click()`)
|
||||
await wait(`!!document.querySelector('input[role="switch"]')`)
|
||||
assert.equal(await js(`document.querySelectorAll('input[role="switch"]').length`), 1)
|
||||
await js(`document.querySelector('input[role="switch"]').click()`)
|
||||
await wait(`document.querySelector('input[role="switch"]').checked`)
|
||||
await js(`document.querySelector('[aria-label="Back to application center"]').click()`)
|
||||
await wait(`!![...document.querySelectorAll('article')].find(e => e.textContent.includes('Device Sharing'))`)
|
||||
await js(`[...document.querySelectorAll('article')].find(e=>e.textContent.includes('Device Sharing')).querySelector('button').click()`)
|
||||
await click('Register this device')
|
||||
await wait(`!!document.querySelector('#sharing-devices + article') || document.querySelector('[aria-labelledby="sharing-devices"]').textContent.includes('Sharing smoke device')`)
|
||||
|
||||
@@ -9,12 +9,13 @@ import '../../src/renderer/src/styles.css'
|
||||
const settings = applicationSettingsSchema.parse({ checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false })
|
||||
function Harness(): React.JSX.Element {
|
||||
const [currentSettings, setSettings] = useState(settings)
|
||||
const [open, setOpen] = useState(true)
|
||||
const [page, setPage] = useState(false)
|
||||
return <UiLocaleProvider initialPreference="en-US">
|
||||
{open && <ApplicationCenter settings={settings} pending={false} onClose={() => setOpen(false)}
|
||||
{open && <ApplicationCenter settings={currentSettings} pending={false} onClose={() => setOpen(false)}
|
||||
onOpen={id => { if (id === 'device-sharing') { setPage(true); setOpen(false) } }}
|
||||
onUpdate={async () => false} onRetry={() => undefined} />}
|
||||
onUpdate={async patch => { setSettings(current => ({ ...current, ...patch })); return true }} onRetry={() => undefined} />}
|
||||
{page && <DeviceSharingPage notify={input => { document.documentElement.dataset.notification = input.message }} />}
|
||||
</UiLocaleProvider>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import assert from 'node:assert/strict'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { AgentEventBuffer } from '../../src/main/agent-event-buffer'
|
||||
import { longAnswerCorpus } from './streaming-markdown-corpus'
|
||||
|
||||
const directory = process.env.GOODBUDDY_STREAMING_INPUT_DIRECTORY!
|
||||
const epoch = () => performance.timeOrigin + performance.now()
|
||||
const duration = 5_000
|
||||
const chunkPeriod = 8
|
||||
const inputPeriod = 71
|
||||
// End on a complete section and add math, rather than truncating a code fence/table.
|
||||
const sectionEnd = longAnswerCorpus.indexOf('## Deployment review', 22_000)
|
||||
const answer = longAnswerCorpus.slice(0, sectionEnd) + '\nInline \\(x^2 + y^2\\) and display math:\n\n\\[E = mc^2\\]\n'
|
||||
const chunkCount = duration / chunkPeriod
|
||||
const chunks = Array.from({ length: chunkCount }, (_, index) => answer.slice(
|
||||
Math.floor(answer.length * index / chunkCount), Math.floor(answer.length * (index + 1) / chunkCount)))
|
||||
const inputOffsets = Array.from({ length: Math.floor((duration - 200) / inputPeriod) }, (_, index) => 103 + index * inputPeriod)
|
||||
const draft = inputOffsets.map((_, index) => String.fromCharCode(97 + index % 26)).join('')
|
||||
const stats = (values: number[]) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return { n: sorted.length, median: (sorted[Math.floor((sorted.length - 1) / 2)]! + sorted[Math.floor(sorted.length / 2)]!) / 2,
|
||||
p95: sorted[Math.ceil(sorted.length * 0.95) - 1]!, max: sorted.at(-1)! }
|
||||
}
|
||||
app.setPath('userData', join(directory, 'profile'))
|
||||
app.commandLine.appendSwitch('force-device-scale-factor', '1')
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ show: true, width: 1100, height: 800,
|
||||
webPreferences: { preload: join(directory, 'preload.cjs'), sandbox: true,
|
||||
contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
|
||||
const errors: string[] = []
|
||||
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
|
||||
await win.loadFile(join(directory, 'renderer', 'streaming-input-page.html'))
|
||||
win.focus()
|
||||
const evaluate = (code: string) => win.webContents.executeJavaScript(`window.streamingInput.${code}`)
|
||||
const reference = await evaluate(`reset(${JSON.stringify(answer)})`)
|
||||
assert(reference.includes('Deployment review'))
|
||||
const results: { interval: number; updates: number; longTasks: number;
|
||||
raw: { sendToInput: number[]; inputToFrame: number[] } }[] = []
|
||||
// Warm one full growing answer, then rotate and reverse the measured order.
|
||||
const order = [32, 16, 50, 32, 80, 80, 32, 50, 16]
|
||||
for (const [position, interval] of order.entries()) {
|
||||
await evaluate('reset()')
|
||||
// Estimate process clock offset while idle; minimum RTT bounds the timestamp uncertainty.
|
||||
const calibration = []
|
||||
for (let sample = 0; sample < 12; sample++) {
|
||||
const before = epoch()
|
||||
const renderer = await evaluate('clock()')
|
||||
const after = epoch()
|
||||
calibration.push({ offset: renderer - (before + after) / 2, rtt: after - before })
|
||||
}
|
||||
const clock = calibration.sort((a, b) => a.rtt - b.rtt)[0]!
|
||||
const sent: number[] = []
|
||||
const chunkLateness: number[] = []
|
||||
const inputLateness: number[] = []
|
||||
let flushes = 0
|
||||
let firstFlush = 0
|
||||
const buffer = new AgentEventBuffer({ flushIntervalMs: interval, onEvent(event) {
|
||||
if (event.type !== 'text') return
|
||||
flushes++
|
||||
if (!firstFlush) firstFlush = epoch()
|
||||
win.webContents.send('streaming-input:chunk', event.delta)
|
||||
} })
|
||||
const start = epoch()
|
||||
// All deadlines are scheduled in main up front. Renderer blockage cannot slow the producer or typing.
|
||||
const arrivals = chunks.map((delta, index) => new Promise<void>(resolve => {
|
||||
const deliver = () => {
|
||||
chunkLateness.push(epoch() - start - index * chunkPeriod)
|
||||
buffer.push({ type: 'text', requestId: 'benchmark', delta })
|
||||
if (index === 0) buffer.flush()
|
||||
resolve()
|
||||
}
|
||||
if (index === 0) deliver()
|
||||
else setTimeout(deliver, Math.max(0, start + index * chunkPeriod - epoch()))
|
||||
}))
|
||||
const typing = inputOffsets.map((offset, index) => new Promise<void>(resolve => {
|
||||
setTimeout(() => {
|
||||
sent.push(epoch())
|
||||
inputLateness.push(sent.at(-1)! - start - offset)
|
||||
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: draft[index]! })
|
||||
win.webContents.sendInputEvent({ type: 'char', keyCode: draft[index]! })
|
||||
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: draft[index]! })
|
||||
resolve()
|
||||
}, Math.max(0, start + offset - epoch()))
|
||||
}))
|
||||
await Promise.all([...arrivals, ...typing, delay(Math.max(0, start + duration - epoch()))])
|
||||
buffer.close()
|
||||
const producerDuration = epoch() - start
|
||||
let ready = false
|
||||
for (let poll = 0; poll < 400; poll++) {
|
||||
const status = await evaluate('status()')
|
||||
if (status.length === answer.length && status.inputs === draft.length && status.frames === draft.length) {
|
||||
ready = true
|
||||
break
|
||||
}
|
||||
await delay(10)
|
||||
}
|
||||
assert(ready, 'renderer must drain the entire independently scheduled stream and native input')
|
||||
const result = await evaluate('result()')
|
||||
assert.equal(result.committed, answer)
|
||||
assert.equal(result.domText, reference)
|
||||
assert.equal(result.draft, draft)
|
||||
assert.equal(result.committedDraft, draft)
|
||||
assert.equal(result.focused, true)
|
||||
assert.equal(result.received, flushes)
|
||||
assert.equal(result.inputs.length, sent.length)
|
||||
for (const [index, input] of result.inputs.entries()) {
|
||||
assert.equal(input.value, draft.slice(0, index + 1))
|
||||
assert.equal(input.trusted, true)
|
||||
}
|
||||
const sendToInput = result.inputs.map((input: { at: number }, index: number) => input.at - clock.offset - sent[index]!)
|
||||
const inputToFrame = result.inputs.map((input: { at: number; frame: number }) => input.frame - input.at)
|
||||
const row = { interval, run: position === 0 ? 'warmup' : position <= 4 ? 1 : 2,
|
||||
sendToInputMs: stats(sendToInput), inputToFrameMs: stats(inputToFrame),
|
||||
sendToFrameMs: stats(sendToInput.map((value: number, index: number) => value + inputToFrame[index])),
|
||||
updates: result.updates, flushes, longTasks: result.tasks.length,
|
||||
longTaskMs: result.tasks.length ? stats(result.tasks) : null,
|
||||
longTaskTotalMs: result.tasks.reduce((sum: number, value: number) => sum + value, 0),
|
||||
producerDurationMs: producerDuration, firstFlushMs: firstFlush - start,
|
||||
firstCommitMs: result.firstCommit - clock.offset - start,
|
||||
lastCommitMs: result.lastCommit - clock.offset - start,
|
||||
chunkLatenessMs: stats(chunkLateness), inputLatenessMs: stats(inputLateness),
|
||||
clockUncertaintyMs: clock.rtt / 2, exactTextDraftFocus: true }
|
||||
console.log('Streaming input run ' + JSON.stringify(row))
|
||||
if (position > 0) results.push({ ...row, raw: { sendToInput, inputToFrame } })
|
||||
}
|
||||
assert.deepEqual(errors, [])
|
||||
const aggregate = [16, 32, 50, 80].map(interval => {
|
||||
const runs = results.filter(row => row.interval === interval)
|
||||
return { interval, sendToInputMs: stats(runs.flatMap(row => row.raw.sendToInput)),
|
||||
inputToFrameMs: stats(runs.flatMap(row => row.raw.inputToFrame)),
|
||||
sendToFrameMs: stats(runs.flatMap(row => row.raw.sendToInput.map((value, index) => value + row.raw.inputToFrame[index]!))),
|
||||
updates: runs.map(row => row.updates), longTasks: runs.map(row => row.longTasks) }
|
||||
})
|
||||
console.log('Streaming input summary ' + JSON.stringify({ electron: process.versions.electron,
|
||||
chromium: process.versions.chrome, renderer: 'production ChatTimeline + controlled textarea (not App)',
|
||||
bytes: Buffer.byteLength(answer), durationMs: duration, chunks: chunks.length, chunkPeriodMs: chunkPeriod,
|
||||
inputPeriodMs: inputPeriod, measuredOrder: order.slice(1), aggregate }))
|
||||
app.exit(0)
|
||||
}).catch(error => { console.error(error); app.exit(1) })
|
||||
@@ -0,0 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>Streaming native input benchmark</title></head>
|
||||
<body><div id="root"></div><script type="module" src="./streaming-input-renderer.tsx"></script></body></html>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('streamingInputBridge', {
|
||||
onChunk: (callback: (delta: string) => void) => {
|
||||
ipcRenderer.on('streaming-input:chunk', (_event, delta) => callback(delta))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useLayoutEffect, useMemo, useState } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ChatTimeline, type Message } from '../../src/renderer/src/ChatTimeline'
|
||||
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
|
||||
import '../../src/renderer/src/fonts'
|
||||
import '../../src/renderer/src/styles.css'
|
||||
|
||||
const epoch = () => performance.timeOrigin + performance.now()
|
||||
const inputs: { at: number; frame: number; value: string; trusted: boolean }[] = []
|
||||
const tasks: number[] = []
|
||||
let collecting = false
|
||||
let committed = ''
|
||||
let committedDraft = ''
|
||||
let updates = 0
|
||||
let received = 0
|
||||
let firstCommit = 0
|
||||
let lastCommit = 0
|
||||
let setContent: (update: string | ((previous: string) => string)) => void
|
||||
let setDraft: (value: string) => void
|
||||
const observer = new PerformanceObserver(list => {
|
||||
if (collecting) tasks.push(...list.getEntries().map(entry => entry.duration))
|
||||
})
|
||||
observer.observe({ type: 'longtask' })
|
||||
const callbacks = {
|
||||
onArticleRef: () => {}, onCopyMessage: async () => true,
|
||||
onDownloadImage: () => {}, onOpenCitationContext: async () => {},
|
||||
onOpenCitationSource: async () => {}, onOpenImage: () => {},
|
||||
onRespondApproval: async () => {}, onRespondQuestion: async () => {},
|
||||
onRetry: () => {}, onRevealEarlier: () => {}
|
||||
}
|
||||
const artifacts = new Map()
|
||||
const initial: Message = { id: 'answer', role: 'assistant', content: '', createdAt: 1, state: 'streaming' }
|
||||
function Bench() {
|
||||
const [content, updateContent] = useState('')
|
||||
const [draft, updateDraft] = useState('')
|
||||
// Preserve ChatTimeline's production memo boundary when only the composer changes.
|
||||
const messages = useMemo(() => [{ ...initial, content }], [content])
|
||||
setContent = updateContent
|
||||
setDraft = updateDraft
|
||||
useLayoutEffect(() => {
|
||||
committed = content
|
||||
if (collecting && content) {
|
||||
updates++
|
||||
lastCommit = epoch()
|
||||
if (!firstCommit) firstCommit = lastCommit
|
||||
}
|
||||
}, [content])
|
||||
useLayoutEffect(() => { committedDraft = draft }, [draft])
|
||||
return <UiLocaleProvider initialPreference="en-US">
|
||||
<main style={{ height: '100vh', display: 'flex', flexDirection: 'column', padding: 16 }}>
|
||||
<section id="answer" style={{ flex: 1, overflow: 'auto' }}>
|
||||
<ChatTimeline {...callbacks} artifactById={artifacts} conversationId="benchmark"
|
||||
hiddenMessageCount={0} isUnusedConversation={false} locale="en-US"
|
||||
messageStartIndex={0} messages={messages} totalMessageCount={1} />
|
||||
</section>
|
||||
<textarea aria-label="Draft" id="draft" value={draft} rows={3}
|
||||
onInput={event => {
|
||||
if (!collecting) return
|
||||
const sample = { at: epoch(), frame: 0, value: event.currentTarget.value,
|
||||
trusted: event.nativeEvent.isTrusted }
|
||||
inputs.push(sample)
|
||||
// Callback execution time includes work queued before this frame; the rAF timestamp does not.
|
||||
requestAnimationFrame(() => { sample.frame = epoch() })
|
||||
}} onChange={event => updateDraft(event.target.value)} />
|
||||
</main>
|
||||
</UiLocaleProvider>
|
||||
}
|
||||
flushSync(() => createRoot(document.getElementById('root')!).render(<Bench />))
|
||||
const bridge = (window as unknown as {
|
||||
streamingInputBridge: { onChunk: (callback: (delta: string) => void) => void }
|
||||
}).streamingInputBridge
|
||||
bridge.onChunk(delta => { received++; setContent(previous => previous + delta) })
|
||||
const nextFrame = () => new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
|
||||
Object.assign(window, { streamingInput: {
|
||||
async reset(content = '') {
|
||||
collecting = false
|
||||
flushSync(() => { setContent(content); setDraft('') })
|
||||
await document.fonts.ready
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
inputs.length = 0
|
||||
tasks.length = 0
|
||||
observer.takeRecords()
|
||||
updates = received = firstCommit = lastCommit = 0
|
||||
document.getElementById('draft')!.focus()
|
||||
collecting = true
|
||||
return document.getElementById('answer')!.textContent
|
||||
},
|
||||
clock: epoch,
|
||||
status: () => ({ length: committed.length, inputs: inputs.length, frames: inputs.filter(input => input.frame).length }),
|
||||
async result() {
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
tasks.push(...observer.takeRecords().map(entry => entry.duration))
|
||||
collecting = false
|
||||
return { committed, committedDraft, domText: document.getElementById('answer')!.textContent,
|
||||
draft: (document.getElementById('draft') as HTMLTextAreaElement).value,
|
||||
focused: document.activeElement?.id === 'draft', inputs, tasks, updates, received, firstCommit, lastCommit }
|
||||
}
|
||||
} })
|
||||
@@ -0,0 +1,118 @@
|
||||
# Native input during streaming
|
||||
|
||||
Run with `npx vitest run tests/streaming-input.electron.test.ts`.
|
||||
The final validated run took 53.37 seconds on Windows, Electron 43.2.0,
|
||||
Chromium 150.0.7871.129. Repository typecheck and lint passed.
|
||||
|
||||
## Method
|
||||
|
||||
- Vite production build of the real `ChatTimeline` and `MarkdownRenderer`,
|
||||
production React, bundled fonts/styles, and a controlled React textarea.
|
||||
The message array remains stable across draft-only changes.
|
||||
- Real `AgentEventBuffer`, bundled into Electron main. The first chunk is
|
||||
explicitly flushed immediately for every interval, including warmup.
|
||||
- Identical 22,716-byte growing Markdown answer: prose, headings, lists,
|
||||
fenced TypeScript, tables, quotation, inline math, and display math.
|
||||
- 625 equal-throughput chunks scheduled independently in main over 5 seconds
|
||||
(8 ms target spacing, approximately 4.54 KB/s).
|
||||
- 67 native key sequences per run, independently scheduled in main every
|
||||
71 ms. Input events must be trusted, preserve every draft prefix, and end
|
||||
with the exact draft and focus intact. No provider/model calls.
|
||||
- One full warmup at 32 ms. Measured order: `16, 50, 32, 80`, then
|
||||
`80, 32, 50, 16`. Two runs and 134 input samples per interval.
|
||||
- Send-to-input uses the timestamp immediately before native key dispatch
|
||||
and the renderer input handler timestamp. Twelve idle clock probes precede
|
||||
each run; the minimum-RTT midpoint estimates process clock offset.
|
||||
Final measured clock uncertainty was at most 0.062 ms.
|
||||
- Input-to-frame ends at execution of the next requestAnimationFrame callback,
|
||||
not its supplied frame timestamp. This is a pre-paint responsiveness proxy,
|
||||
not physical screen presentation latency.
|
||||
- Counts include committed answer updates and Chromium long tasks (over 50 ms).
|
||||
Final committed Markdown and DOM text must exactly match the source and an
|
||||
independently rendered static reference, respectively.
|
||||
- Renderer timers never drive arrivals or typing. Output includes main timer
|
||||
lateness, producer duration, first flush/commit, and final commit times.
|
||||
Assertions cover correctness, not machine-dependent performance thresholds.
|
||||
|
||||
## Final Results
|
||||
|
||||
All latency cells are **median / p95 / maximum**, in milliseconds. Pooled
|
||||
percentiles are computed from samples, not averages of per-run percentiles.
|
||||
|
||||
| Interval | Send to input | Input to next frame | Send to next frame | Answer updates, runs 1 / 2 | Long tasks |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 16 ms | 0.80 / 12.54 / 18.92 | 0.80 / 13.30 / 27.90 | 3.23 / 15.57 / 28.64 | 164 / 164 | 0 / 0 |
|
||||
| 32 ms | 0.71 / 11.33 / 24.83 | 0.60 / 16.20 / 25.40 | 2.17 / 17.34 / 26.20 | 110 / 109 | 0 / 0 |
|
||||
| 50 ms | 0.72 / 4.35 / 23.69 | 0.50 / 17.80 / 22.30 | 1.28 / 18.85 / 24.29 | 82 / 82 | 0 / 0 |
|
||||
| 80 ms | 0.71 / 10.17 / 20.81 | 0.40 / 7.70 / 14.20 | 1.16 / 13.63 / 21.51 | 55 / 55 | 0 / 0 |
|
||||
|
||||
Per-run p95 values make the variability visible:
|
||||
|
||||
| Interval | Send to input, runs 1 / 2 | Input to frame, runs 1 / 2 | Send to frame, runs 1 / 2 |
|
||||
| --- | --- | --- | --- |
|
||||
| 16 ms | 6.51 / 13.44 | 13.30 / 6.80 | 14.81 / 16.68 |
|
||||
| 32 ms | 11.32 / 11.33 | 16.60 / 11.40 | 17.82 / 16.04 |
|
||||
| 50 ms | 1.11 / 4.35 | 17.80 / 15.40 | 18.51 / 20.18 |
|
||||
| 80 ms | 10.44 / 4.93 | 10.60 / 7.40 | 13.63 / 12.90 |
|
||||
|
||||
All nine runs, including warmup, preserved exact final Markdown, rendered DOM
|
||||
text, draft, each typed prefix, and textarea focus. Every emitted buffer flush
|
||||
was received. Measured first flushes occurred within 0.08 ms of producer start;
|
||||
first commits within 1.58 ms. Producer durations were 5,002.79-5,013.54 ms.
|
||||
|
||||
## Interpretation
|
||||
|
||||
**50 ms is the smallest candidate with a material send-to-input p95 improvement**
|
||||
in this measurement: 65% below 16 ms, with half as many answer updates. It is
|
||||
not an overall latency win: send-to-next-frame p95 is 21% higher than at 16 ms.
|
||||
|
||||
80 ms has the best pooled send-to-next-frame p95 (13.63 ms versus 15.57 ms at
|
||||
16 ms), and the best input-to-frame tail. That approximately 1.94 ms end-to-end
|
||||
p95 saving costs two thirds of the answer updates. Two short runs do not
|
||||
establish a robust benefit sufficient to recommend raising the production
|
||||
default to 80 ms. This benchmark does not justify claiming 32 ms materially
|
||||
improves tail input latency, either. Preserve the production default unless
|
||||
the chosen target is specifically send-to-input latency; for that target,
|
||||
50 ms is the smallest measured candidate to investigate in the complete App.
|
||||
|
||||
## After segmented Markdown rendering
|
||||
|
||||
`MarkdownRenderer` now splits Markdown at safe top-level block boundaries and
|
||||
memoizes each segment, so a streaming update re-parses only the growing tail.
|
||||
Same benchmark, same machine, same order (`16, 50, 32, 80, 80, 32, 50, 16`),
|
||||
two runs and 134 input samples per interval; exact text, draft, every typed
|
||||
prefix and focus were preserved in all nine runs, with zero long tasks.
|
||||
|
||||
| Interval | Send to input p95, before / after | Send to next frame p95, before / after | Answer updates |
|
||||
| --- | --- | --- | --- |
|
||||
| 16 ms | 12.54 / 1.23 | 15.57 / 7.94 | 163 / 164 |
|
||||
| 32 ms | 11.33 / 1.27 | 17.34 / 8.23 | 109 / 110 |
|
||||
| 50 ms | 4.35 / 1.29 | 18.85 / 7.21 | 82 / 82 |
|
||||
| 80 ms | 10.17 / 1.39 | 13.63 / 7.57 | 55 / 55 |
|
||||
|
||||
With segmentation the interval no longer materially changes input latency,
|
||||
so the production 16 ms default is retained: streaming cadence is unchanged.
|
||||
|
||||
`tests/streaming-markdown.electron.test.ts` adds a streaming-delta case: 120
|
||||
characters appended per update to an approximately 80 KB answer, on persistent
|
||||
roots. Whole-document rendering took median 59.2 ms (p95 65.4 ms); segmented
|
||||
rendering took median 2.4 ms (p95 4.2 ms), with DOM equality checked against
|
||||
whole-document rendering throughout.
|
||||
|
||||
## Limits
|
||||
|
||||
This mounts production ChatTimeline, not App. It excludes App event reduction,
|
||||
conversation history, the production composer, and App auto-scroll. The answer
|
||||
pane stays at its initial scroll position. DOM work occurs for the entire
|
||||
answer, but this does not measure continuously painting its moving bottom edge.
|
||||
The static reference checks DOM text equivalence, not pixel equivalence.
|
||||
|
||||
Windows timers coalesced: chunk scheduling p95 lateness was approximately
|
||||
12.7-13.5 ms, and native-input scheduling p95 lateness 12.4-14.1 ms. Therefore
|
||||
configured 16 ms did not produce 62.5 updates/second: it produced approximately
|
||||
32.8. Lateness is reported separately and excluded from send-to-input latency,
|
||||
which starts at actual dispatch. The producer never waits for the renderer.
|
||||
|
||||
This is one machine without CPU throttling and one moderate-throughput workload.
|
||||
There were no long tasks at any interval. A slower machine, larger history,
|
||||
App auto-scroll, or faster generation may change the tradeoff substantially.
|
||||
@@ -86,7 +86,7 @@ async function run() {
|
||||
for (let round = 0; round < 4; round++) {
|
||||
render(current.MarkdownRenderer, '')
|
||||
flushSync(() => originalRoot.render(<UiLocaleProvider initialPreference="en-US">
|
||||
<original.MarkdownRenderer>{''}</original.MarkdownRenderer>
|
||||
<original.UnsegmentedMarkdownRenderer>{''}</original.UnsegmentedMarkdownRenderer>
|
||||
</UiLocaleProvider>))
|
||||
for (const [index, length] of prefixLengths.entries()) {
|
||||
const prefix = longAnswerCorpus.slice(0, length)
|
||||
@@ -98,7 +98,7 @@ async function run() {
|
||||
const start = performance.now()
|
||||
if (variant === 'original') {
|
||||
flushSync(() => originalRoot.render(<UiLocaleProvider initialPreference="en-US">
|
||||
<original.MarkdownRenderer>{prefix}</original.MarkdownRenderer>
|
||||
<original.UnsegmentedMarkdownRenderer>{prefix}</original.UnsegmentedMarkdownRenderer>
|
||||
</UiLocaleProvider>))
|
||||
void originalHost.offsetHeight
|
||||
} else {
|
||||
@@ -119,6 +119,48 @@ async function run() {
|
||||
flushSync(() => originalRoot.unmount())
|
||||
originalHost.remove()
|
||||
}
|
||||
// Streaming shape: small deltas appended to an already long answer, on
|
||||
// persistent roots, as ChatTimeline receives them. Alternate variant order.
|
||||
const deltaSamples = { original: [] as number[], current: [] as number[] }
|
||||
const deltaHost = document.createElement('div')
|
||||
deltaHost.className = host.className
|
||||
deltaHost.style.cssText = host.style.cssText
|
||||
document.body.append(deltaHost)
|
||||
const deltaRoot = createRoot(deltaHost)
|
||||
const deltaStart = Math.floor(longAnswerCorpus.length * 0.8)
|
||||
const deltaSize = 120
|
||||
try {
|
||||
const renderDelta = (variant: 'original' | 'current', prefix: string) => {
|
||||
if (variant === 'original') {
|
||||
flushSync(() => deltaRoot.render(<UiLocaleProvider initialPreference="en-US">
|
||||
<original.UnsegmentedMarkdownRenderer>{prefix}</original.UnsegmentedMarkdownRenderer>
|
||||
</UiLocaleProvider>))
|
||||
void deltaHost.offsetHeight
|
||||
} else {
|
||||
render(current.MarkdownRenderer, prefix)
|
||||
void host.offsetHeight
|
||||
}
|
||||
}
|
||||
renderDelta('original', longAnswerCorpus.slice(0, deltaStart))
|
||||
renderDelta('current', longAnswerCorpus.slice(0, deltaStart))
|
||||
for (let step = 1; step <= 60; step++) {
|
||||
const prefix = longAnswerCorpus.slice(0, deltaStart + step * deltaSize)
|
||||
const order = step % 2 === 0 ? ['original', 'current'] as const : ['current', 'original'] as const
|
||||
for (const variant of order) {
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
|
||||
const start = performance.now()
|
||||
renderDelta(variant, prefix)
|
||||
if (step > 5) deltaSamples[variant].push(performance.now() - start)
|
||||
}
|
||||
if (step % 10 === 0 && snapshot() !== snapshot(deltaHost)) throw new Error(`Delta DOM mismatch: ${step}`)
|
||||
}
|
||||
} finally {
|
||||
flushSync(() => deltaRoot.unmount())
|
||||
deltaHost.remove()
|
||||
}
|
||||
const streamingDelta = { startCharacters: deltaStart, deltaCharacters: deltaSize,
|
||||
samplesPerVariant: deltaSamples.current.length,
|
||||
wholeDocumentMs: stats(deltaSamples.original), segmentedMs: stats(deltaSamples.current) }
|
||||
const longAnswer = { characters: longAnswerCorpus.length, bytes: new TextEncoder().encode(longAnswerCorpus).length,
|
||||
prefixLengths, warmupSweeps: 1, measuredSweeps: 3, samplesPerVariant: longSamples.current.length,
|
||||
originalRenderMs: stats(longSamples.original), currentRenderMs: stats(longSamples.current),
|
||||
@@ -131,7 +173,7 @@ async function run() {
|
||||
if (normalize(prefix) !== originalNormalize(prefix)) throw new Error(`Normalizer mismatch: ${JSON.stringify(prefix)}`)
|
||||
prefixesChecked++
|
||||
for (const renderHtml of [false, true]) {
|
||||
render(original.MarkdownRenderer, prefix, renderHtml)
|
||||
render(original.UnsegmentedMarkdownRenderer, prefix, renderHtml)
|
||||
const expected = snapshot()
|
||||
render(current.MarkdownRenderer, prefix, renderHtml)
|
||||
if (snapshot() !== expected) throw new Error(`DOM mismatch (${renderHtml}): ${JSON.stringify(prefix)}`)
|
||||
@@ -143,7 +185,7 @@ async function run() {
|
||||
if (!host.querySelector('h1') || !host.querySelector('strong') || !host.querySelector('.katex')) {
|
||||
throw new Error('Real Markdown formatting missing')
|
||||
}
|
||||
return { results, longAnswer, prefixesChecked, domChecks }
|
||||
return { results, longAnswer, streamingDelta, prefixesChecked, domChecks }
|
||||
}
|
||||
|
||||
Object.assign(window, { streamingMarkdown: run })
|
||||
|
||||
@@ -65,6 +65,50 @@ app.whenReady().then(async () => {
|
||||
for (const locale of ['zh-CN', 'en-US']) {
|
||||
for (const theme of ['light', 'dark']) {
|
||||
win.setContentSize(1000, 720)
|
||||
if (process.env.GB_LAYOUT_MODE === 'source') {
|
||||
await win.loadURL(`${process.env.GB_LAYOUT_URL}?surface=settings&locale=${locale}&theme=${theme}`)
|
||||
await wait('!!document.querySelector(".model-download-source-control small") && !document.querySelector(".model-download-source-control [role=status]")')
|
||||
await js('document.fonts.ready')
|
||||
for (const [width, height] of [[640, 420], [960, 720], [1280, 800]]) {
|
||||
win.setContentSize(width, height)
|
||||
await wait(`innerWidth === ${width} && innerHeight === ${height}`)
|
||||
await js("document.querySelector('.settings-panel__content').scrollTop=0")
|
||||
await settle()
|
||||
const layout = await js(`(() => {
|
||||
const row=document.querySelector('.model-connections-navigation'),tabs=row.querySelector('.segmented-control'),button=row.querySelector('.model-download-source-control button');
|
||||
const r=row.getBoundingClientRect(),t=tabs.getBoundingClientRect(),b=button.getBoundingClientRect();
|
||||
return {rightAligned:Math.abs(r.right-b.right)<1,sameRow:Math.abs(t.y+t.height/2-b.y-b.height/2)<1,
|
||||
noOverlap:t.right<=b.left,overflow:row.scrollWidth>row.clientWidth+1,hit:button.contains(document.elementFromPoint(b.x+b.width/2,b.y+b.height/2))};
|
||||
})()`)
|
||||
assert.deepEqual(layout, {rightAligned:true,sameRow:true,noOverlap:true,overflow:false,hit:true}, JSON.stringify({locale,theme,width,layout}))
|
||||
const trigger = '.model-download-source-control button'
|
||||
const dialog = '[aria-labelledby="model-download-source-title"]'
|
||||
await click(trigger)
|
||||
await wait(`!!document.querySelector('${dialog}') && document.activeElement.matches('${dialog} .icon-button')`)
|
||||
await click(`${dialog} input[value="hugging-face"]`)
|
||||
assert.equal(await js('window.sourceFixture.writes.length'), 0)
|
||||
await click(`${dialog} .custom-task-dialog__actions .secondary-button`)
|
||||
await wait(`!document.querySelector('${dialog}') && document.activeElement.matches('${trigger}')`)
|
||||
assert.equal(await js('window.sourceFixture.source'), 'modelscope')
|
||||
await click(trigger)
|
||||
await wait(`!!document.querySelector('${dialog} input[value="modelscope"]:checked')`)
|
||||
await click(`${dialog} input[value="hugging-face"]`)
|
||||
await click(`${dialog} .custom-task-dialog__actions .primary-button`)
|
||||
await wait(`!document.querySelector('${dialog}') && document.activeElement.matches('${trigger}') && document.querySelector('.model-download-source-control small').textContent.includes('Hugging Face')`)
|
||||
assert.deepEqual(await js('window.sourceFixture.writes'), [{modelDownloadSource:'hugging-face'}])
|
||||
await click(trigger)
|
||||
await wait(`!!document.querySelector('${dialog} input[value="hugging-face"]:checked')`)
|
||||
await key('Escape')
|
||||
await wait(`!document.querySelector('${dialog}') && document.activeElement.matches('${trigger}')`)
|
||||
await screenshot(`${locale}-${theme}-source-${width}`)
|
||||
observations.push({locale,theme,width,height,layout,dialog:'native selection, cancel without write, confirm, reopen, Escape and focus restoration'})
|
||||
// Reload the fixture to reset its in-memory settings and write log.
|
||||
await win.loadURL(`${process.env.GB_LAYOUT_URL}?surface=settings&locale=${locale}&theme=${theme}`)
|
||||
await wait('!!document.querySelector(".model-download-source-control small") && !document.querySelector(".model-download-source-control [role=status]")')
|
||||
await js('document.fonts.ready')
|
||||
}
|
||||
continue
|
||||
}
|
||||
await win.loadURL(`${process.env.GB_LAYOUT_URL}?locale=${locale}&theme=${theme}`)
|
||||
await wait('!!document.querySelector(".workbar-shell__scroll-button")')
|
||||
await js('document.fonts.ready')
|
||||
@@ -144,12 +188,13 @@ app.whenReady().then(async () => {
|
||||
return {containerWidth:manager.clientWidth,stacked:detail.top>=list.bottom-1,selectWidth:e.clientWidth,
|
||||
textWidth:c.measureText(e.selectedOptions[0].textContent).width,available:e.clientWidth-parseFloat(s.paddingLeft)-parseFloat(s.paddingRight)-20,
|
||||
overflow:['.settings-panel__content','.model-connection-manager','.model-connection-detail'].filter(sel=>{const n=document.querySelector(sel);return n.scrollWidth>n.clientWidth+1}),
|
||||
hit:e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2)),value:e.value};
|
||||
hit:e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2)),value:e.value,
|
||||
bounds:r.toJSON(),cover:document.elementFromPoint(r.x+r.width/2,r.y+r.height/2)?.outerHTML.slice(0,500)};
|
||||
})()`)
|
||||
assert.equal(result.stacked, result.containerWidth <= 640, JSON.stringify(result))
|
||||
assert.deepEqual(result.overflow, [], JSON.stringify(result))
|
||||
assert(result.textWidth <= result.available, 'Protocol truncated: ' + JSON.stringify(result))
|
||||
assert(result.hit, 'Protocol is occluded')
|
||||
assert(result.hit, 'Protocol is occluded: ' + JSON.stringify({width,height,...result}))
|
||||
await js("document.querySelector('.model-connection-detail select').closest('.field').previousElementSibling.querySelector('input').focus()")
|
||||
await key('Tab')
|
||||
await wait('document.activeElement.matches(".model-connection-detail select")')
|
||||
|
||||
@@ -8,6 +8,7 @@ import { applyAppearanceTheme } from '../../src/renderer/src/theme'
|
||||
import { installBundledUiFonts } from '../../src/renderer/src/fonts'
|
||||
import { defaultRuntimeSettings, type DesktopApi } from '../../src/shared/contracts'
|
||||
import type { WorkbarTabInstance } from '../../src/shared/workbar-contracts'
|
||||
import type { ApplicationSettingsUpdate, ModelDownloadSource } from '../../src/shared/application-settings-contracts'
|
||||
import '../../src/renderer/src/styles.css'
|
||||
|
||||
const query = new URLSearchParams(location.search)
|
||||
@@ -16,7 +17,18 @@ const longName = locale === 'en-US'
|
||||
? 'Organization shared model connection for document processing and analysis'
|
||||
: '\u7ec4\u7ec7\u5185\u90e8\u6587\u6863\u5904\u7406\u4e0e\u5206\u6790\u4e13\u7528\u5171\u4eab\u6a21\u578b\u8fde\u63a5'
|
||||
const profileId = '10000000-0000-4000-8000-000000000001'
|
||||
const sourceFixture = { source: 'modelscope' as ModelDownloadSource, writes: [] as ApplicationSettingsUpdate[] }
|
||||
Object.defineProperty(window, 'sourceFixture', { value: sourceFixture })
|
||||
Object.defineProperty(window, 'goodbuddy', { value: {
|
||||
updates: {
|
||||
getSettings: async () => ({ modelDownloadSource: sourceFixture.source }),
|
||||
onSettingsChanged: () => () => {},
|
||||
updateSettings: async (input: ApplicationSettingsUpdate) => {
|
||||
sourceFixture.writes.push(input)
|
||||
if (input.modelDownloadSource) sourceFixture.source = input.modelDownloadSource
|
||||
return { modelDownloadSource: sourceFixture.source }
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
getRuntime: async () => ({ ...defaultRuntimeSettings, modelProfiles: [{
|
||||
id: profileId, name: longName, baseUrl: 'http://fixture.invalid/v1', modelName: 'fixture-model',
|
||||
|
||||
@@ -8,7 +8,10 @@ import { createServer } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
it('keeps workbar add reachable and model protocols readable at narrow container widths', async () => {
|
||||
it.each([
|
||||
{ mode: 'layout', name: 'keeps workbar add reachable and model protocols readable at narrow container widths' },
|
||||
{ mode: 'source', name: 'aligns the model download source button and supports native dialog confirm and cancel' }
|
||||
])('$name', async ({ mode }) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-responsive-controls-'))
|
||||
const server = await createServer({
|
||||
configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
|
||||
@@ -26,7 +29,7 @@ it('keeps workbar add reachable and model protocols readable at narrow container
|
||||
await server.listen()
|
||||
const driver = join(directory, 'driver.mjs')
|
||||
await copyFile(resolve('tests/support/workbar-settings-layout-driver.mjs'), driver)
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, GB_LAYOUT_DIRECTORY: directory,
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, GB_LAYOUT_DIRECTORY: directory, GB_LAYOUT_MODE: mode,
|
||||
GB_LAYOUT_URL: server.resolvedUrls!.local[0] + 'responsive.html' }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
|
||||
Reference in New Issue
Block a user