feat: preview Agent HTML in conversations and unblock long replies

Agent replies that produced HTML could only be read as source, so users had
to copy the markup into an external file to see the result. Completed Agent
and Subagent output now renders complete HTML documents, single-root HTML
replies, and `html` / `htm` code blocks as an isolated static preview in
place. Each preview keeps a persistent notice that scripts and network are
disabled and offers the same icon actions as Mermaid diagrams: a source
toggle and a full-screen preview. The new "render HTML in conversations"
switch under Platform Features / General is on by default, applies to the
open conversation immediately, and falls back to HTML source when disabled.
Previews cannot run scripts, reach the network, submit forms, open windows,
or load remote media; author stylesheets and inline styles are preserved so
the intended layout survives, and remote resource loading is refused by the
preview CSP instead.

Direct-model replies no longer fail once they grow large. The byte caps on
streaming responses, single stream blocks, non-streaming replies, model tool
arguments, tool request context, aggregate tool results, and accumulated
answer text are removed, so users stop seeing "模型接口流式响应超过安全限制"
on long answers. Response length is now governed by the request timeout,
cancellation, and context compression. Image generation responses keep their
decodable-image validation.

A message sent after a failed or interrupted run stayed in the pending queue
instead of running. The conversation is now released whenever a run reaches
any terminal state, not only when the renderer reports readiness again, and a
dispatched queue item whose run never starts releases its reservation and
returns to pending. Later messages dispatch without extra user action.

Release note: Agent 回复中的 HTML 现在可直接在会话内预览,支持查看源码和全屏预览,默认开启并可在“平台功能 / 通用设置”关闭;直连模型的长回复不再因大小限制中断;上一次运行失败或被中断后,新发送的消息会立即执行而不再排队等待。
This commit is contained in:
mesalogo
2026-09-05 09:10:44 +08:00
parent 9588c4b901
commit a4b64a1641
35 changed files with 1669 additions and 195 deletions
+7 -3
View File
@@ -32,9 +32,13 @@ otherwise.
Host heading and only the remote path on each project row.
- [x] **File, screenshot, window, and clipboard context**: Added to model
context only after explicit user selection.
- [x] **Rich responses**: Supports GitHub Flavored Markdown, LaTeX math, and
constrained Mermaid diagrams. Large diagrams can be zoomed, panned, or
viewed as source, and failed renders preserve the original diagram code.
- [x] **Rich responses**: Supports GitHub Flavored Markdown, LaTeX math,
constrained Mermaid diagrams, and static in-conversation HTML previews.
Complete HTML and HTML code blocks can be previewed in place after an Agent
reply finishes, with icon actions to reveal the source or open a full-screen
preview. The feature is on by default and can be disabled under Platform
Features / General; previews cannot run scripts, access the network, submit
forms, or open windows.
- [x] **AI response and full-conversation copy**: Completed AI responses expose
a bottom action that copies Markdown source without reasoning, tool logs, or
citation metadata. Full-conversation copy uses the same validated
+1 -1
View File
@@ -18,7 +18,7 @@
- [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。
- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,托管 SSH 项目按 Host 分组并在 Host 标题显示真实 Agent 连接状态,项目行只保留远端路径。
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式和受控 Mermaid 图表;大图可缩放、拖动或查看源码,失败时保留原始图表代码。
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式、受控 Mermaid 图表和会话内 HTML 静态预览。Agent 完成回复后,完整 HTML 与 HTML 代码块可在原位置预览,并通过图标按钮查看源码或打开全屏预览;该能力默认开启,可在“平台功能 / 通用设置”关闭,预览不执行脚本、联网、表单提交或窗口操作。
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
- [x] **助手工作栏、多终端与可调布局**:右侧工作栏使用持久“+”能力目录和应用 Tab;用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记列表支持鼠标与键盘调宽,魔法笔记列表可隐藏并记住布局,窄宽度下自动堆叠。
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
+10
View File
@@ -580,6 +580,13 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 已结束且包含正文的助手回复在底部提供带可访问名称和工具提示的复制图标;复制 Markdown
原文,不包含推理过程、工具日志或引用元数据,结果通过应用通知反馈。流式回复完成前不显示
复制入口。
- 默认开启“会话 HTML 渲染”时,已完成 Agent 回复中的完整 HTML 与 `html` / `htm`
代码块在原位置显示隔离静态预览;用户消息、推理过程和流式中的回复不得执行 HTML。
每个预览持续标注脚本和网络已禁用,并在标题右侧复用与 Mermaid 图表相同的图标按钮组:
源码按钮使用代码图标并暴露 `aria-expanded`,全屏按钮使用放大图标打开统一 Modal。
全屏预览遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape 关闭和焦点恢复规则,
并沿用同一套清理与 Sandbox/CSP 边界。关闭平台功能后必须显示 HTML 源码,不能隐藏内容
或保留旧预览。
- 本地会话的更多操作提供“在新会话中继续”。创建前必须先持久化当前稳定消息;有活动运行
或待发送内容时不可创建。新会话复制聊天内容与当前 Runtime/知识检索选择,重置压缩状态,
使用新的会话和消息 ID,且不复制 Task、队列、运行、记忆或成果归属。
@@ -690,6 +697,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- “模型连接”的认证字段按“认证方式 → API Key → 凭据状态”排列;只有选择 API Key 时显示输入框。已保存的 API Key 属于该连接,修改模型服务地址或临时选择“无需认证”不得自动清除,也不得要求重新输入;仅由明确的“清除凭据”或“删除连接”操作移除。
- “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 HTTP 状态、模型列表或健康检查响应不算测试成功。界面持续说明该操作可能产生少量服务商用量费用,成功反馈明确写为“真实生成测试通过”。
- 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。
- “平台功能 / 通用设置”同时提供默认开启的“在会话中渲染 HTML”持久 Switch。说明必须
明确该能力只生成禁用脚本、联网、表单提交和窗口操作的静态预览;保存成功后当前会话立即
更新,保存失败时保留最后确认状态。
- “平台功能”使用共享 `PageTabs` 区分“通用设置”“远程项目(技术预览)”和“魔法笔记”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64/arm64 Agent 包清单分成独立卡片;清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后仍可选择更新源,手动检查继续使用当前选择。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。
- “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图;另提供默认关闭的原生 Checkbox,让用户主动附加最近桌面诊断摘要。Checkbox 附近必须持续说明摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息,不含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。勾选时为诊断摘要预留描述字符预算,超长草稿必须保留并显示字段错误,不得静默截断。失败时保留草稿、勾选状态和请求编号并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。
@@ -0,0 +1,29 @@
# 会话 HTML 渲染
会话 HTML 渲染让用户直接查看 Agent 回复中的网页结果,而不必先复制代码到外部文件。
该能力默认开启,也可以在“设置 > 平台功能 > 通用设置”中关闭。
## 功能边界
- 只渲染已完成的 Agent 正文和已完成的子 Agent 输出。
- 支持完整 HTML 文档、单一 HTML 根元素,以及 Markdown 中的 `html` / `htm` 代码块。
- 使用无脚本、无网络、无表单提交和无窗口操作权限的静态隔离预览。
- 每个预览提供源码切换和全屏预览两个图标操作;全屏预览沿用同一清理和隔离边界。
- 用户消息、推理过程和流式中的回复不执行 HTML;关闭功能后继续显示 HTML 源码。
- 本功能不把普通聊天回复复制为成果,也不替代助手工作栏中独立成果的预览流程。
## 文档
- [产品需求](./prd.md):功能范围、状态规则和验收标准。
- [技术设计](./technical-design.md):设置持久化、渲染边界与静态隔离方案。
- [实现进度](./progress.md):已验证实现和验证记录。
## 术语
- **HTML 源码**:Agent 回复中原始、可复制的 HTML 文本。
- **静态预览**:在隔离 `iframe` 中显示的 HTML;脚本、联网和外部操作均被禁用。
- **全屏预览**:在应用级 Modal 中显示同一份静态预览文档,用于查看完整页面布局。
全局聊天和设置界面规则由根目录的
[统一界面设计系统](../../../UI-DESIGN.md)定义。独立成果预览仍由
[助手工作栏](../assistant-workbar/README.md)负责。
@@ -0,0 +1,69 @@
# 会话 HTML 渲染产品需求
## 用户问题
Agent 可以生成网页、报告和交互原型的 HTML,但此前会话只能显示源码或忽略原始标签。
用户必须把内容复制到外部文件后才能查看结果,打断了会话内的阅读和迭代流程。
## 目标
- 让 Agent 生成的 HTML 在回复完成后直接显示为网页预览。
- 保留一键查看原始 HTML 的能力。
- 允许用户通过平台功能开关恢复为只看源码。
- 预览不取得 GoodBuddy Renderer 或外部网络能力。
## 非目标
- 不执行 Agent 提供的 JavaScript。
- 不提供联网网页、表单、弹窗或 Electron `webview`。
- 不在流式输出中反复加载不完整页面。
- 不自动把普通会话回复保存为成果。
## 功能需求
### FR-1 默认设置
“会话 HTML 渲染”是应用级持久设置,默认开启。用户可在“设置 > 平台功能 >
通用设置”中立即关闭或重新开启;保存失败时保留最后确认的状态并显示错误。
### FR-2 可识别输出
开启后,已完成 Agent 回复中的以下内容显示为静态预览:
1. 完整 HTML 文档。
2. 由单一 HTML 根元素构成的完整回复。
3. Markdown 中标记为 `html` 或 `htm` 的代码块。
普通 Markdown 继续按原有规则显示。一个回复中的多个 HTML 代码块各自保持原有位置。
### FR-3 回复状态和角色
- 已完成的主 Agent 正文和已完成的子 Agent 输出可以预览 HTML。
- 用户消息和 Agent 推理只显示文本或源码。
- 流式 Agent 正文在完成前显示源码,完成后原位切换为预览。
### FR-4 源码、全屏与关闭行为
每个预览持续显示“静态安全预览,脚本和网络已禁用”,并在标题右侧提供两个图标操作:
1. 源码按钮,就地展开或收起原始 HTML。
2. 全屏按钮,在应用级 Modal 中查看同一份预览文档。
嵌入预览使用固定高度并在框内滚动,需要更大画面时使用全屏预览。全屏预览可通过关闭
按钮、Escape 或点击遮罩退出,退出后焦点回到全屏按钮。关闭平台功能后,完整 HTML 回复
和 HTML 代码块继续显示源码,不能变成空白。
### FR-5 静态隔离
预览必须禁用脚本、网络请求、表单提交、嵌套页面、插件对象和窗口操作。远程链接和远程
媒体不得从预览加载;内联样式和有界的内联位图可用于还原静态视觉。
## 产品验收
- 新安装和从旧设置升级后,开关均为开启。
- 完整 HTML 和 fenced HTML 在已完成的 Agent 回复中显示预览。
- 关闭开关后,当前会话立即恢复显示源码,重启后选择仍保留。
- 流式回复、推理和用户消息从不执行 HTML。
- 预览可通过键盘访问源码和全屏按钮,并具有明确的 iframe 标题。
- 全屏预览隔离背景、初始聚焦关闭按钮,Escape 退出后焦点回到全屏按钮。
- 恶意脚本、事件属性、远程资源、表单和嵌套 frame 不进入可执行预览。
@@ -0,0 +1,39 @@
# 会话 HTML 渲染实现进度
## 已实现
- [x] 应用级持久开关,默认开启,并提供旧设置迁移。
- [x] 平台功能通用设置中的共享 Switch。
- [x] 已完成 Agent 回复和子 Agent 输出中的完整 HTML 与 fenced HTML 识别。
- [x] 隔离静态预览、持续安全说明和源码切换。
- [x] 预览保留作者 `<style>` 与 `style` 属性,样式不再被整块丢弃。
- [x] 与 Mermaid 一致的图标按钮组:源码切换与全屏预览 Modal。
- [x] 嵌入预览固定 480px 高度,源码面板同值上限。
- [x] 用户消息、推理、流式回复和关闭状态的源码边界。
- [x] 设置变更后当前会话立即重新渲染。
## 验证记录
2026-09-05:
- `npm run typecheck`:通过。
- 聚焦测试(应用设置、Markdown、会话时间线、设置中心、App 集成):306 项通过。
- 全量 `npm test`:`3493 passed, 55 skipped`。
- `npm run lint`:通过。
- `npm run build`:Main、Preload、Renderer 与控制面安装器均成功构建。
修复样式丢失后复验:
- 原实现只要 `<style>` 或 `style` 属性中出现 `@import` / `url()` 就整块删除,带渐变或
背景图的回复因此完全失去样式;现改为保留作者样式,外部资源加载仍由 iframe CSP 拒绝。
- `npx vitest run src/renderer/src/MarkdownRenderer.test.tsx`:`19 passed`。
- 聚焦测试(会话时间线、App 集成、设置中心):`260 passed`。
- `npm run typecheck`、`npm run lint`:通过。
新增全屏预览与图标化源码按钮后复验:
- 源码按钮改为代码图标并暴露 `aria-expanded`;新增放大图标打开全屏 Modal。
- 嵌入预览高度由 `min-height: 320px` 改为固定 `height: 480px`,源码面板上限同步为 480px。
- 新增回归测试覆盖全屏 Modal 的初始焦点、背景 inert、Sandbox/CSP 与 Escape 焦点恢复。
- 聚焦测试(Markdown、会话时间线、App 集成、设置中心):`280 passed`。
- `npm run typecheck`、`npm run lint`:通过。
@@ -0,0 +1,68 @@
# 会话 HTML 渲染技术设计
## 进程和设置边界
`conversationHtmlRenderingEnabled` 属于现有 `ApplicationSettings`。Main 继续通过
版本化 JSON 文件持久化应用设置,IPC 继续复用已验证的
`settings:application:get/update` 通道,Preload 只暴露类型化设置方法。
应用设置格式升级到版本 11。版本 10 及更早设置在读取时补入默认值 `true`,无需保留第二份
状态或独立迁移文件。
## Renderer 数据流
1. `App` 启动时读取应用设置,并把当前值传给每个保活的会话视图。
2. 平台功能 Switch 保存成功后立即更新 `App` 状态。
3. `ChatTimeline` 只为 `role === "assistant"` 且 `state === "complete"` 的正文传入
HTML 渲染权限;推理始终不传入该权限。
4. `MarkdownRenderer` 识别完整 HTML 回复以及 `html` / `htm` fenced code。
5. 关闭权限或回复仍在流式阶段时,HTML 保持代码文本。
## 静态预览
`StaticHtmlPreview` 不使用 `dangerouslySetInnerHTML`。它先用 DOMPurify 清理文档,再通过
`DOMParser` 移除事件属性、非锚点 `href` 和非内联图片的 `src` / `poster`,最后把结果写入
`<iframe sandbox="" srcDoc>`。
作者样式必须保留:`<style>` 元素和 `style` 属性原样传入预览,不按 `@import` 或 `url()`
关键字整块删除,否则任何带渐变或背景图的回复都会退化成无样式文档。外部样式表和远程
字体、图片的实际加载由 iframe 文档的 CSP 拒绝,不需要在清理阶段重复拦截。
iframe 文档注入严格 CSP:
- `default-src 'none'`
- `script-src 'none'`
- `connect-src 'none'`
- `frame-src 'none'`
- `object-src 'none'`
- `form-action 'none'`
- 只允许内联样式以及 `data:` 字体和受支持的内联位图
同时设置 `referrerPolicy="no-referrer"`。Sandbox 不授予 scripts、forms、popups、
same-origin 或 navigation 权限。预览只在回复完成后创建,并使用 lazy loading。
## UI 与无障碍
预览沿用聊天阅读宽度、语义颜色和共享 `icon-button`。`figure` / `figcaption` 表达预览及其
说明,iframe 具有本地化标题。标题右侧是一个 `role="group"` 图标按钮组,与 Mermaid 图表
操作保持一致:源码按钮暴露 `aria-expanded` 与 `aria-controls`,全屏按钮打开 Modal。
嵌入预览高度固定为 480px,超出部分在框内滚动;查看源码面板使用同一数值作为最大高度。
iframe 无法按内容自适应高度,因为 `sandbox=""` 不含 `allow-same-origin` 或 `allow-scripts`,
主文档既读不到内部文档尺寸,也不能接收上报;全屏预览覆盖需要更多空间的场景。
全屏预览复用 `dialog-focus` 的 `activateModalFocus` 与 `trapTabFocus`,遵循统一 Modal 的
背景 inert、初始焦点落在关闭按钮、Tab 循环、Escape 关闭和关闭后焦点恢复。它渲染的是与
嵌入预览完全相同的清理后文档,不额外放宽 Sandbox 或 CSP。切换回复时,源码面板与全屏
预览按当前 source 派生开合状态,不使用会引发级联渲染的重置 effect。
## 验证范围
- 应用设置默认值、关闭持久化和版本 10 迁移。
- fenced HTML、完整 HTML、关闭后的源码回退。
- 作者 `<style>` 与 `style` 属性在预览中保留。
- 脚本、远程资源、表单和事件属性清理,以及 iframe Sandbox/CSP。
- 源码按钮的 `aria-expanded` 切换,以及全屏预览的初始焦点、背景 inert、Escape 关闭和
焦点恢复。
- 主 Agent、子 Agent、用户、推理和流式状态边界。
- 从设置 Switch 到当前会话重新渲染的应用级集成路径。
+14 -2
View File
@@ -6,8 +6,8 @@
| --- | --- |
| 文档类型 | 功能进度 |
| 状态 | 已实施,本机验收完成 |
| 版本 | 0.3 |
| 日期 | 2026-09-03 |
| 版本 | 0.4 |
| 日期 | 2026-09-05 |
| 关联入口 | [直连模型 Agent 能力](./README.md) |
## 当前结论
@@ -126,6 +126,18 @@
- `npm run build`:Main、Preload、Renderer 和控制面安装器均成功构建。2026-09-02 按产品
决策移除未对应实际性能问题的 Renderer bundle 基线门禁后复验通过。
## 2026-09-05 移除直连模型响应字节上限
- 直连模型不再对流式响应总量、单个流式块、非流式回复、模型工具参数、工具请求上下文、
聚合工具结果和累计回答长度设置字节上限;长回复和大工具结果由请求超时、取消和上下文
压缩控制。用户不再看到“模型接口流式响应超过安全限制”。
- 图像生成响应仍按可解码图片大小校验,Provider 错误响应仍读取有界文本。
- 回归测试改为验证超过旧上限的长流式回复可以完整返回,以及超过 1 MB 的图片工具结果会
进入下一轮模型请求。
- `npx vitest run src/main/agent/model-runtime.test.ts`:`74 passed, 1 skipped`。
- 全量 `npm test`:`3493 passed, 55 skipped`。
- `npm run typecheck`、`npm run lint` 和 `npm run build`:通过。
## 进度维护要求
- 只有真实生产路径完成并验证后才能勾选实施项。
@@ -327,11 +327,15 @@ type SubagentActor =
不要用宽泛 catch 抹掉取消、超时、Provider HTTP 状态或进程退出上下文。
模型请求重试由 `ModelAgentRuntime` 的统一流式请求边界处理,覆盖普通问答、工具轮次和复用
模型请求重试由 `ModelAgentRuntime` 的统一流式请求处理,覆盖普通问答、工具轮次和复用
同一 Runtime 的上下文摘要。重试分类检查错误及有界 `cause` 链中的 Node/Undici 网络错误
码;每次尝试创建独立超时信号。退避等待监听父请求取消。只有完整模型轮次返回后才执行
工具,因此未产生可见文本或推理内容的重试不会重复执行工具。
模型回复本身不设固定字节上限:流式响应、非流式回复、模型工具参数、请求上下文和工具
结果总量都由请求超时、取消和上下文压缩控制,长回复和大工具结果不会被中断。图像生成
响应仍按可解码图片大小校验。
## 11. 性能
- Shell 探测按执行服务实例缓存,不在每轮模型调用重复探测。
@@ -231,8 +231,10 @@ type TimeTrigger =
同一 Conversation 的普通消息和 Scheduled Task occurrence 使用同一 FIFO 队列。Agent
正在回复时,到期 occurrence 只显示为待执行,不中断当前输出;当前执行结束后才认领下一项。
用户显式选择“立即中断并插入”时,系统取消当前 Conversation 的活动请求,并让所选项成为
下一项。删除待执行 occurrence 只取消该次运行,不删除稳定 Task、Conversation 或历史结果。
执行以失败、取消或中断结束时同样立即释放该 Conversation,下一项无需用户再次操作即会
执行。用户显式选择“立即中断并插入”时,系统取消当前 Conversation 的活动请求,并让所选项
成为下一项。删除待执行 occurrence 只取消该次运行,不删除稳定 Task、Conversation 或历史
结果。
## 8. 一次触发的对象关系
@@ -365,6 +367,8 @@ Task Center 显示 Scheduled Task 的范围、关联 Conversation、状态、模
- [x] 当前回复期间可以继续发送普通消息,并在 Composer 上沿查看、删除或提升待发送项。
- [x] 应用重启恢复尚未执行的队列项和有界附件上下文;已写入 Conversation 的用户输入
通过队列项身份完成恢复对账,不会再次进入待发送队列。
- [x] 执行失败、取消或中断后立即释放会话,下一项和随后发送的新消息无需用户额外操作
即会执行。
- [x] 文本结果只写入 Conversation,独立交付物才进入成果。
- [ ] Task Center 和桌面通知可以打开正确 Conversation 并定位 Task。
- [ ] 应用重启不自动重放结果未知的副作用。
+105 -62
View File
@@ -1788,19 +1788,41 @@ describe('ModelAgentRuntime', () => {
}
})
it('bounds the total ordinary streaming response size', async () => {
const chunk = new TextEncoder().encode(
`data: ${JSON.stringify({
type: 'content_block_delta',
delta: {
type: 'text_delta',
text: 'x'.repeat(65_000)
}
})}\n\n`
)
it('streams long ordinary responses without a size limit', async () => {
const encoder = new TextEncoder()
const deltaChunks = 48
const deltaText = 'x'.repeat(65_000)
const body = new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(chunk)
start(controller) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: 'message_start',
message: {
id: 'message-long',
model: 'sonnet-5',
usage: { input_tokens: 5 }
}
})}\n\n`
)
)
for (let index = 0; index < deltaChunks; index += 1) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: 'content_block_delta',
delta: {
type: 'text_delta',
text: deltaText
}
})}\n\n`
)
)
}
controller.enqueue(
encoder.encode('data: {"type":"message_stop"}\n\n')
)
controller.close()
}
})
const runtime = new ModelAgentRuntime({
@@ -1816,22 +1838,26 @@ describe('ModelAgentRuntime', () => {
})
)
})
const consume = async (): Promise<void> => {
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
void _event
let streamedText = ''
let done = false
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
if (event.type === 'text') {
streamedText += event.delta
}
if (event.type === 'done') {
done = true
}
}
await expect(consume()).rejects.toThrow(
'流式响应超过安全限制'
)
expect(done).toBe(true)
expect(streamedText.length).toBe(deltaChunks * deltaText.length)
})
it('preserves bounded provider error messages', async () => {
@@ -5192,29 +5218,44 @@ describe('ModelAgentRuntime', () => {
await replacement.dispose()
})
it('counts image base64 data against the aggregate tool context limit', async () => {
const response = {
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call-large-image',
type: 'function',
function: {
name: 'workspace_read_text',
arguments: '{}'
}
}
]
}
}
]
}
it('keeps an oversized image tool result in the model round', async () => {
const imageData = Buffer.alloc(1024 * 1024 + 1).toString('base64')
const fetcher = vi.fn<typeof fetch>(async () => Response.json(response))
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
Response.json({
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call-large-image',
type: 'function',
function: {
name: 'workspace_read_text',
arguments: '{}'
}
}
]
}
}
]
})
)
.mockResolvedValueOnce(
Response.json({
choices: [
{
message: {
role: 'assistant',
content: '图片已读取。'
}
}
]
})
)
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
@@ -5234,23 +5275,25 @@ describe('ModelAgentRuntime', () => {
}))
})
})
const consume = async (): Promise<void> => {
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'run',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
void _event
}
const events = []
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'run',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
events.push(event)
}
await expect(consume()).rejects.toThrow('结果总量超过 1MB')
expect(fetcher).toHaveBeenCalledOnce()
expect(fetcher).toHaveBeenCalledTimes(2)
expect(events.at(-1)).toMatchObject({ type: 'done' })
expect(fetcher.mock.calls[1]?.[1]?.body as string).toContain(
imageData
)
})
it('performs and validates a real image generation when testing an image connection', async () => {
+31 -95
View File
@@ -189,10 +189,6 @@ type ModelToolResponse = {
const maxGeneratedImageBytes = 3_900_000
const maxImageResponseBytes = 5_300_000
const maxChatResponseBytes = 2 * 1024 * 1024
const maxStreamBlockBytes = 1024 * 1024
const maxToolArgumentBytes = 128 * 1024
const maxToolContextBytes = 1024 * 1024
const defaultModelRequestTimeoutMs = 10 * 60_000
const modelRequestRetryDelaysMs = [500, 1_000, 2_000] as const
const retryablePreDispatchNetworkErrorCodes = new Set([
@@ -734,12 +730,16 @@ function parseGeneratedImage(value: unknown): {
throw new Error('图像生成接口返回了不支持的图片格式')
}
async function readResponseText(response: Response): Promise<string> {
if (!response.body) {
throw new Error('模型接口未返回响应内容')
}
return response.text()
}
function parseToolArguments(value: unknown): Record<string, unknown> {
let parsed = value
if (typeof value === 'string') {
if (Buffer.byteLength(value) > maxToolArgumentBytes) {
throw new Error('模型工具参数超过 128KB 安全限制')
}
try {
parsed = JSON.parse(value)
} catch (error) {
@@ -751,15 +751,11 @@ function parseToolArguments(value: unknown): Record<string, unknown> {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('模型工具参数必须是 JSON object')
}
let serialized: string
try {
serialized = JSON.stringify(parsed)
JSON.stringify(parsed)
} catch (error) {
throw new Error('模型工具参数无法序列化', { cause: error })
}
if (Buffer.byteLength(serialized) > maxToolArgumentBytes) {
throw new Error('模型工具参数超过 128KB 安全限制')
}
return parsed as Record<string, unknown>
}
@@ -1232,7 +1228,7 @@ function parseSseData(
}
}
async function* readBoundedSseBlocks(
async function* readSseBlocks(
response: Response
): AsyncGenerator<string, void, void> {
if (!response.body) {
@@ -1242,19 +1238,11 @@ async function* readBoundedSseBlocks(
const decoder = new TextDecoder()
let buffer = ''
let completed = false
let receivedBytes = 0
try {
while (true) {
const { done, value } = await reader.read()
receivedBytes += value?.byteLength ?? 0
if (receivedBytes > maxChatResponseBytes) {
throw new Error('模型接口流式响应超过安全限制')
}
buffer += decoder.decode(value, { stream: !done })
buffer = buffer.replaceAll('\r\n', '\n')
if (Buffer.byteLength(buffer) > maxStreamBlockBytes) {
throw new Error('模型接口流式响应块超过安全限制')
}
const blocks = buffer.split('\n\n')
buffer = blocks.pop() ?? ''
@@ -1288,7 +1276,7 @@ async function* readOpenAIResponsesToolStream(
reported: false
}
for await (const block of readBoundedSseBlocks(response)) {
for await (const block of readSseBlocks(response)) {
const parsed = parseSseData(block)
if (parsed.stopped) {
break
@@ -1423,7 +1411,7 @@ async function* readAnthropicToolStream(
let reasoning = ''
let stopReason: unknown
for await (const block of readBoundedSseBlocks(response)) {
for await (const block of readSseBlocks(response)) {
const parsed = parseSseData(block)
if (parsed.stopped) {
break
@@ -1566,12 +1554,6 @@ async function* readAnthropicToolStream(
throw new Error('Anthropic 返回了无效流式工具参数增量')
}
current.partialJson += delta.partial_json
if (
Buffer.byteLength(current.partialJson) >
maxToolArgumentBytes
) {
throw new Error('模型工具参数超过 128KB 安全限制')
}
}
continue
}
@@ -1952,13 +1934,14 @@ export class ModelAgentRuntime implements AgentRuntime {
)
)
})
const responseText = await readBoundedResponseText(response, {
maxBytes: response.ok && imageGeneration
? maxImageResponseBytes
: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
const responseText =
response.ok && imageGeneration
? await readBoundedResponseText(response, {
maxBytes: maxImageResponseBytes,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
: await readResponseText(response)
if (!response.ok) {
let detail: string | undefined
try {
@@ -2660,13 +2643,13 @@ export class ModelAgentRuntime implements AgentRuntime {
const response = modelRequest.response
let responseText: string
try {
responseText = await readBoundedResponseText(response, {
maxBytes: response.ok
? maxImageResponseBytes
: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
responseText = response.ok
? await readBoundedResponseText(response, {
maxBytes: maxImageResponseBytes,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
: await readResponseText(response)
} catch (error) {
return normalizeRequestError(error, modelRequest.timedOut())
} finally {
@@ -2828,9 +2811,6 @@ export class ModelAgentRuntime implements AgentRuntime {
}
)
)
if (Buffer.byteLength(body) > 2 * 1024 * 1024) {
throw new Error('模型工具请求上下文超过 2MB 安全限制')
}
const request = await this.fetchWithTimeout(
this.getEndpoint(),
{
@@ -2843,11 +2823,7 @@ export class ModelAgentRuntime implements AgentRuntime {
const response = request.response
try {
if (!response.ok) {
const responseText = await readBoundedResponseText(response, {
maxBytes: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
const responseText = await readResponseText(response)
let detail: string | undefined
try {
detail = getErrorMessage(
@@ -2890,7 +2866,7 @@ export class ModelAgentRuntime implements AgentRuntime {
let reasoning = ''
let receivedStop = false
for await (const block of readBoundedSseBlocks(response)) {
for await (const block of readSseBlocks(response)) {
const parsed = parseSseData(block)
if (parsed.stopped) {
receivedStop = true
@@ -2972,16 +2948,6 @@ export class ModelAgentRuntime implements AgentRuntime {
? functionDelta.name
: current.name
}
if (
next.id.length > 256 ||
next.name.length > 128 ||
Buffer.byteLength(next.arguments) >
maxToolArgumentBytes
) {
throw new Error(
'OpenAI 模型接口返回的流式工具调用超过安全限制'
)
}
streamedToolCalls.set(index as number, next)
}
}
@@ -3025,11 +2991,7 @@ export class ModelAgentRuntime implements AgentRuntime {
streamed: true
}
}
const responseText = await readBoundedResponseText(response, {
maxBytes: maxChatResponseBytes,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
const responseText = await readResponseText(response)
let payload: unknown
try {
payload = responseText.trim()
@@ -3115,11 +3077,7 @@ export class ModelAgentRuntime implements AgentRuntime {
} satisfies ModelUsageAccumulator
try {
if (!response.ok) {
const responseText = await readBoundedResponseText(response, {
maxBytes: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
const responseText = await readResponseText(response)
let detail: string | undefined
try {
detail = getErrorMessage(
@@ -3136,7 +3094,7 @@ export class ModelAgentRuntime implements AgentRuntime {
}
let receivedStop = false
for await (const block of readBoundedSseBlocks(response)) {
for await (const block of readSseBlocks(response)) {
const parsed = parseStreamBlock(block, this.options.protocol)
if (parsed.usage) {
applyUsageUpdate(usage, parsed.usage)
@@ -3619,9 +3577,6 @@ export class ModelAgentRuntime implements AgentRuntime {
}
if (response.text) {
answer += response.text
if (Buffer.byteLength(answer) > 1024 * 1024) {
throw new Error('直连模型回答超过 1MB 安全限制')
}
if (!response.streamed) {
yield {
requestId: request.requestId,
@@ -3844,25 +3799,6 @@ export class ModelAgentRuntime implements AgentRuntime {
}
}
roundContextBytes += validateToolResult(result)
const retainedContextBytes = compressionState.rounds.reduce(
(total, retainedRound) =>
total + retainedRound.contextBytes,
roundContextBytes
)
if (retainedContextBytes > maxToolContextBytes) {
if (showToolActivity) {
yield {
requestId: request.requestId,
type: 'tool',
callId: call.id,
name: displayName,
state: 'failed',
summary: `直连模型工具结果超过限制:${displayName}`,
input
}
}
throw new Error('直连模型工具结果总量超过 1MB 安全限制')
}
if (responses) {
responsesResults.push({
type: 'function_call_output',
+68 -10
View File
@@ -65,6 +65,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
@@ -76,6 +77,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
@@ -83,11 +85,12 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 10,
version: 11,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
@@ -116,7 +119,7 @@ describe('ApplicationSettingsStore', () => {
remoteProjectsEnabled: true
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 10,
version: 11,
lastSeenReleaseNotesVersion: null,
...defaultApplicationSettings,
remoteProjectsEnabled: true
@@ -139,6 +142,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -165,6 +169,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -191,6 +196,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -217,6 +223,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -245,11 +252,12 @@ describe('ApplicationSettingsStore', () => {
createApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 10,
version: 11,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -279,6 +287,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -306,6 +315,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'mirror',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -314,9 +324,10 @@ describe('ApplicationSettingsStore', () => {
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionSix,
version: 10,
version: 11,
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
@@ -324,9 +335,10 @@ describe('ApplicationSettingsStore', () => {
await store.update({ modelDownloadSource: 'hugging-face' })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionSix,
version: 10,
version: 11,
modelDownloadSource: 'hugging-face',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
@@ -351,6 +363,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -359,8 +372,9 @@ describe('ApplicationSettingsStore', () => {
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionSeven,
version: 10,
version: 11,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
@@ -388,6 +402,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'mirror',
modelDownloadSource: 'hugging-face',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
@@ -397,17 +412,19 @@ describe('ApplicationSettingsStore', () => {
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionEight,
version: 10,
version: 11,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false
})
await store.update({ checkUpdatesOnStartup: true })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionEight,
version: 10,
version: 11,
checkUpdatesOnStartup: true,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false
})
})
@@ -445,6 +462,7 @@ describe('ApplicationSettingsStore', () => {
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative',
conversationHtmlRenderingEnabled: true,
localToolEnvironment: {
node: {
source: 'custom',
@@ -459,7 +477,8 @@ describe('ApplicationSettingsStore', () => {
const persisted = JSON.parse(await readFile(filePath, 'utf8'))
expect(persisted).toEqual({
...versionNine,
version: 10,
version: 11,
conversationHtmlRenderingEnabled: true,
localToolEnvironment: {
node: {
source: 'custom',
@@ -523,6 +542,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -546,6 +566,41 @@ describe('ApplicationSettingsStore', () => {
})
})
it('persists disabling conversation HTML rendering', async () => {
const { filePath, store } = await createStore()
await expect(
store.update({ conversationHtmlRenderingEnabled: false })
).resolves.toEqual({
...defaultApplicationSettings,
conversationHtmlRenderingEnabled: false
})
await expect(
createApplicationSettingsStore(filePath).get()
).resolves.toEqual({
...defaultApplicationSettings,
conversationHtmlRenderingEnabled: false
})
})
it('enables conversation HTML rendering when migrating version 10 settings', async () => {
const { filePath, store } = await createStore()
const versionTenSettings: Record<string, unknown> = {
...defaultApplicationSettings,
version: 10,
lastSeenReleaseNotesVersion: '0.8.18'
}
delete versionTenSettings.conversationHtmlRenderingEnabled
await writeFile(filePath, JSON.stringify(versionTenSettings), 'utf8')
await expect(store.get()).resolves.toEqual(defaultApplicationSettings)
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionTenSettings,
version: 11,
conversationHtmlRenderingEnabled: true
})
})
it.each([
'{not-json',
JSON.stringify({
@@ -634,6 +689,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -641,11 +697,12 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 10,
version: 11,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -674,6 +731,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
+44 -4
View File
@@ -27,7 +27,7 @@ export {
} from '../shared/application-settings-contracts'
export type { ApplicationSettings } from '../shared/application-settings-contracts'
const CURRENT_SETTINGS_VERSION = 10
const CURRENT_SETTINGS_VERSION = 11
const legacyStoredApplicationSettingsSchema = z
.object({
@@ -58,6 +58,7 @@ const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema
updateSource: true,
modelDownloadSource: true,
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -71,6 +72,7 @@ const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema
updateSource: true,
modelDownloadSource: true,
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -84,6 +86,7 @@ const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
modelDownloadSource: true,
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -96,6 +99,7 @@ const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
const versionSevenStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -108,6 +112,7 @@ const versionSevenStoredApplicationSettingsSchema = applicationSettingsSchema
const versionEightStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true
})
.extend({
@@ -117,13 +122,25 @@ const versionEightStoredApplicationSettingsSchema = applicationSettingsSchema
.strict()
const versionNineStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({ localToolEnvironment: true })
.omit({
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true
})
.extend({
version: z.literal(9),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionTenStoredApplicationSettingsSchema =
applicationSettingsSchema
.omit({ conversationHtmlRenderingEnabled: true })
.extend({
version: z.literal(10),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const storedApplicationSettingsSchema = applicationSettingsSchema
.extend({
version: z.literal(CURRENT_SETTINGS_VERSION),
@@ -215,6 +232,7 @@ export const defaultApplicationSettings: ApplicationSettings = {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -236,8 +254,13 @@ export class ApplicationSettingsStore {
private async migrateLegacy(
settings: Omit<
StoredApplicationSettings,
'localToolEnvironment' | 'version'
> & { version: number }
| 'conversationHtmlRenderingEnabled'
| 'localToolEnvironment'
| 'version'
> & {
conversationHtmlRenderingEnabled?: boolean
version: number
}
): Promise<StoredApplicationSettings> {
const resolved = await this.legacyLocalToolEnvironmentResolver()
const selection = (
@@ -249,6 +272,8 @@ export class ApplicationSettingsStore {
: { source: 'managed' }
const next: StoredApplicationSettings = {
...settings,
conversationHtmlRenderingEnabled:
settings.conversationHtmlRenderingEnabled ?? true,
version: CURRENT_SETTINGS_VERSION,
localToolEnvironment: {
node: selection(resolved.nodeExecutablePath),
@@ -303,6 +328,17 @@ export class ApplicationSettingsStore {
)
const result = storedApplicationSettingsSchema.safeParse(parsed)
if (!result.success) {
const versionTenResult =
versionTenStoredApplicationSettingsSchema.safeParse(parsed)
if (versionTenResult.success) {
const next: StoredApplicationSettings = {
...versionTenResult.data,
version: CURRENT_SETTINGS_VERSION,
conversationHtmlRenderingEnabled: true
}
await this.persist(next)
return next
}
const versionNineResult =
versionNineStoredApplicationSettingsSchema.safeParse(parsed)
if (versionNineResult.success) {
@@ -443,6 +479,8 @@ export class ApplicationSettingsStore {
updateSource: stored.updateSource,
modelDownloadSource: stored.modelDownloadSource,
localToolEnvironment: stored.localToolEnvironment,
conversationHtmlRenderingEnabled:
stored.conversationHtmlRenderingEnabled,
remoteProjectsEnabled: stored.remoteProjectsEnabled,
magicNotesEnabled: stored.magicNotesEnabled,
magicNotesShowIncompleteTodoCount:
@@ -478,6 +516,8 @@ export class ApplicationSettingsStore {
updateSource: next.updateSource,
modelDownloadSource: next.modelDownloadSource,
localToolEnvironment: next.localToolEnvironment,
conversationHtmlRenderingEnabled:
next.conversationHtmlRenderingEnabled,
remoteProjectsEnabled: next.remoteProjectsEnabled,
magicNotesEnabled: next.magicNotesEnabled,
magicNotesShowIncompleteTodoCount:
+215
View File
@@ -6710,6 +6710,221 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose()
})
it('releases the queue reservation when a dispatched run cannot start', async () => {
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: false,
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId,
type: 'done'
} as const
}
}
const harness = createHarness(runtime)
const conversationId =
'00000000-0000-4000-8000-000000000745'
const itemId = '00000000-0000-4000-8000-000000000746'
const input = {
conversationId,
runtimeSelection: { provider: 'auto' as const },
workMode: 'ask' as const,
includeMemoryContext: true,
prompt: '需要重新排队',
attachments: [],
knowledgeLibraryIds: [],
knowledgeRetrievalMode: 'auto' as const
}
const item = {
id: itemId,
conversationId,
source: 'user' as const,
label: input.prompt,
createdAt: '2026-08-21T00:02:00.000Z'
}
harness.assistantDatabase.enqueueConversationUserInput.mockReturnValue(
item
)
harness.assistantDatabase.listConversationQueueItems
.mockReturnValueOnce([item])
.mockReturnValue([])
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce(
{
source: 'user',
item,
payloadJson: JSON.stringify({
input,
serializedContexts: '[]'
})
}
)
harness.assistantDatabase.getConversationQueueItem.mockReturnValue(
item
)
harness.assistantDatabase.createTask.mockImplementationOnce(() => {
throw new Error('会话记录不可用')
})
electronMocks.handlers.get(
ipcChannels.conversationQueueEnqueueUser
)?.(trustedEvent(harness.webContents), input)
await vi.waitFor(() =>
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationQueueDispatch,
{ item, input }
)
)
await expect(
harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000747',
conversationId,
queueItemId: itemId,
runtimeSelection: input.runtimeSelection,
prompt: input.prompt,
workMode: input.workMode,
knowledgeLibraryIds: []
})
).rejects.toThrow('会话记录不可用')
expect(
harness.assistantDatabase.releaseConversationUserQueueItem
).toHaveBeenCalledWith(itemId)
await harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000748',
conversationId,
runtimeSelection: { provider: 'auto' },
prompt: '失败后的新消息',
workMode: 'ask',
knowledgeLibraryIds: []
})
await harness.dispose()
})
it('dispatches a message queued during a run that later fails', async () => {
let failRun: ((error: Error) => void) | undefined
const runtimeStarted = vi.fn()
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: false,
// eslint-disable-next-line require-yield
async *run(request: { requestId: string }) {
runtimeStarted(request.requestId)
throw await new Promise<Error>((resolve) => {
failRun = resolve
})
}
}
const harness = createHarness(runtime)
const conversationId =
'00000000-0000-4000-8000-000000000751'
const createItem = (id: string, prompt: string) => {
const input = {
conversationId,
runtimeSelection: { provider: 'auto' as const },
workMode: 'ask' as const,
includeMemoryContext: true,
prompt,
attachments: [],
knowledgeLibraryIds: [],
knowledgeRetrievalMode: 'auto' as const
}
return {
input,
item: {
id,
conversationId,
source: 'user' as const,
label: prompt,
createdAt: '2026-08-21T00:03:00.000Z'
}
}
}
const first = createItem(
'00000000-0000-4000-8000-000000000752',
'会失败的消息'
)
const second = createItem(
'00000000-0000-4000-8000-000000000753',
'失败之后的新消息'
)
harness.assistantDatabase.enqueueConversationUserInput
.mockReturnValueOnce(first.item)
.mockReturnValueOnce(second.item)
harness.assistantDatabase.listConversationQueueItems
.mockReturnValueOnce([first.item])
.mockReturnValueOnce([second.item])
.mockReturnValue([])
harness.assistantDatabase.claimConversationQueueItem
.mockReturnValueOnce({
source: 'user',
item: first.item,
payloadJson: JSON.stringify({
input: first.input,
serializedContexts: '[]'
})
})
.mockReturnValueOnce({
source: 'user',
item: second.item,
payloadJson: JSON.stringify({
input: second.input,
serializedContexts: '[]'
})
})
harness.assistantDatabase.getConversationQueueItem.mockImplementation(
(id: string) =>
id === first.item.id ? first.item : second.item
)
electronMocks.handlers.get(
ipcChannels.conversationQueueEnqueueUser
)?.(trustedEvent(harness.webContents), first.input)
await vi.waitFor(() =>
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationQueueDispatch,
{ item: first.item, input: first.input }
)
)
await harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000754',
conversationId,
queueItemId: first.item.id,
runtimeSelection: first.input.runtimeSelection,
prompt: first.input.prompt,
workMode: first.input.workMode,
knowledgeLibraryIds: []
})
await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled())
// The next message arrives while the run is still in flight, so the
// renderer never reports readiness for it afterwards.
electronMocks.handlers.get(
ipcChannels.conversationQueueEnqueueUser
)?.(trustedEvent(harness.webContents), second.input)
failRun?.(new Error('模型接口请求失败'))
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(
'00000000-0000-4000-8000-000000000754',
'failed',
expect.any(String)
)
)
await vi.waitFor(() =>
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationQueueDispatch,
{ item: second.item, input: second.input }
)
)
await harness.dispose()
})
it('publishes Runtime usage as context metrics with one settings read', async () => {
const runtime = {
runtimeId: 'continue',
+34 -3
View File
@@ -4527,9 +4527,11 @@ export function registerIpcHandlers(
pendingGoodBuddyConfigReload = true
}
await flushGoodBuddyConfigReload().catch(() => undefined)
if (readyConversationQueues.has(request.conversationId)) {
void pumpConversationQueue(request.conversationId)
}
// The renderer also reports readiness after a finished run, but a
// run that fails before publishing a terminal event never triggers
// that call, which would leave later messages queued forever.
readyConversationQueues.add(request.conversationId)
void pumpConversationQueue(request.conversationId)
}
})()
if (managedSshExecution) {
@@ -4542,6 +4544,35 @@ export function registerIpcHandlers(
}
} finally {
preparingRequestConversations.delete(parsedInput.requestId)
// A dispatched queue item whose run never started keeps the
// conversation reserved, so later messages would stay queued
// forever instead of running.
if (
parsedInput.queueItemId &&
reservedConversationQueueItems.get(
parsedInput.conversationId
) === parsedInput.queueItemId
) {
reservedConversationQueueItems.delete(
parsedInput.conversationId
)
const dispatchTimeout = queueDispatchTimers.get(
parsedInput.queueItemId
)
if (dispatchTimeout) {
clearTimeout(dispatchTimeout)
queueDispatchTimers.delete(parsedInput.queueItemId)
}
try {
assistantDatabase.releaseConversationUserQueueItem(
parsedInput.queueItemId
)
} catch {
// The renderer may have released the item already.
}
readyConversationQueues.add(parsedInput.conversationId)
publishConversationQueueChange(parsedInput.conversationId)
}
}
})
+93
View File
@@ -1007,6 +1007,7 @@ function installRemoteProjectsSetting(enabled: boolean): {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: enabled,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -2195,6 +2196,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -2296,6 +2298,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -2307,6 +2310,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -2408,6 +2412,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -2419,6 +2424,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -4455,6 +4461,7 @@ describe('App', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -10952,6 +10959,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -10963,6 +10971,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -11014,6 +11023,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -11068,6 +11078,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
@@ -11101,6 +11112,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -11112,6 +11124,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -11142,12 +11155,92 @@ describe('App', () => {
).not.toBeInTheDocument()
})
it('renders Agent HTML by default and applies the platform switch immediately', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000471'
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: conversationId,
projectId,
title: 'HTML 预览会话',
updatedAt: 1_775_000_000_000,
messages: [
{
id: '00000000-0000-4000-8000-000000000472',
role: 'assistant',
content: `\`\`\`html
<main><h1>Rendered dashboard</h1></main>
\`\`\``,
createdAt: 1_775_000_000_000,
state: 'complete'
}
]
}
])
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
}
const updateSettings = vi.fn<
NonNullable<DesktopApi['updates']>['updateSettings']
>(async (input) => {
applicationSettings = { ...applicationSettings, ...input }
return { ...applicationSettings }
})
api.updates = {
getSettings: vi.fn(async () => ({ ...applicationSettings })),
updateSettings,
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {})
}
render(<App />)
expect(
await screen.findByTitle('Agent 回复 HTML 静态预览')
).toHaveAttribute('sandbox', '')
fireEvent.click(
await screen.findByRole('button', { name: /本地工作区/u })
)
fireEvent.click(
await screen.findByRole('tab', { name: '平台功能' })
)
const toggle = await screen.findByRole('switch', {
name: '在会话中渲染 HTML'
})
expect(toggle).toBeChecked()
fireEvent.click(toggle)
await waitFor(() =>
expect(updateSettings).toHaveBeenCalledWith({
conversationHtmlRenderingEnabled: false
})
)
await waitFor(() =>
expect(
screen.queryByTitle('Agent 回复 HTML 静态预览')
).not.toBeInTheDocument()
)
expect(screen.getByText(/Rendered dashboard/u)).toBeInTheDocument()
})
it('keeps platform-feature switches in Settings without navigating', async () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
+16
View File
@@ -846,6 +846,7 @@ type ChatScrollSnapshot = {
function ChatHistoryPane({
active,
artifactById,
conversationHtmlRenderingEnabled,
conversation,
locale,
onCopyMessage,
@@ -866,6 +867,7 @@ function ChatHistoryPane({
}: {
active: boolean
artifactById: ReadonlyMap<string, AssistantArtifact>
conversationHtmlRenderingEnabled: boolean
conversation: Conversation
locale: TimeFormatLocale
onCopyMessage: (content: string) => Promise<void>
@@ -1169,6 +1171,7 @@ function ChatHistoryPane({
onRespondQuestion={onRespondQuestion}
onRetry={onRetry}
onRevealEarlier={revealEarlierMessages}
renderAssistantHtml={conversationHtmlRenderingEnabled}
retryContent={
conversation.messages.at(-2)?.role === 'user'
? conversation.messages.at(-2)?.content
@@ -2548,6 +2551,10 @@ function App(): React.JSX.Element {
useState<ProjectChannel>()
const [remoteProjectsEnabled, setRemoteProjectsEnabled] =
useState(false)
const [
conversationHtmlRenderingEnabled,
setConversationHtmlRenderingEnabled
] = useState(true)
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
const [
magicNotesShowIncompleteTodoCount,
@@ -3077,6 +3084,9 @@ function App(): React.JSX.Element {
void updates
.getSettings()
.then(async (settings) => {
setConversationHtmlRenderingEnabled(
settings.conversationHtmlRenderingEnabled !== false
)
setRemoteProjectsEnabled(settings.remoteProjectsEnabled)
setMagicNotesEnabled(settings.magicNotesEnabled)
setMagicNotesShowIncompleteTodoCount(
@@ -9372,6 +9382,9 @@ function App(): React.JSX.Element {
view === 'chat' && conversation.id === activeId
}
artifactById={assistantArtifactById}
conversationHtmlRenderingEnabled={
conversationHtmlRenderingEnabled
}
conversation={conversation}
key={conversation.id}
locale={locale}
@@ -10831,6 +10844,9 @@ function App(): React.JSX.Element {
initialChannel={settingsInitialChannel}
magicNotesEnabled={magicNotesEnabled}
remoteProjectsEnabled={remoteProjectsEnabled}
onConversationHtmlRenderingEnabledChange={
setConversationHtmlRenderingEnabled
}
onAppearanceThemeChange={setAppearanceTheme}
onBrandingPreferencesChange={(preferences) => {
if (!saveBrandingPreferences(preferences)) {
+59 -1
View File
@@ -12,10 +12,20 @@ import {
} from './ChatTimeline'
const markdownRenderProbe = vi.hoisted(() => vi.fn())
const htmlRenderProbe = vi.hoisted(() => vi.fn())
vi.mock('./MarkdownRenderer', () => ({
MarkdownRenderer: ({ children }: { children: string }) => {
MarkdownRenderer: ({
children,
renderHtml
}: {
children: string
renderHtml?: boolean
}) => {
markdownRenderProbe(children)
if (renderHtml) {
htmlRenderProbe(children)
}
return <span>{children}</span>
}
}))
@@ -50,6 +60,54 @@ describe('ChatTimeline', () => {
vi.clearAllMocks()
})
it('enables HTML rendering only for completed Agent output', () => {
const messages: Message[] = [
{
id: 'user-message',
role: 'user',
content: '<div>User HTML</div>',
createdAt: 1_775_000_000_000,
state: 'complete'
},
{
id: 'streaming-message',
role: 'assistant',
content: '<div>Streaming HTML</div>',
createdAt: 1_775_000_001_000,
state: 'streaming'
},
{
id: 'complete-message',
role: 'assistant',
content: '<div>Complete HTML</div>',
reasoning: '<div>Reasoning HTML</div>',
createdAt: 1_775_000_002_000,
state: 'complete'
}
]
render(
<ChatTimeline
artifactById={new Map()}
conversationId="conversation-1"
hiddenMessageCount={0}
isUnusedConversation={false}
locale="zh-CN"
messageStartIndex={0}
messages={messages}
{...callbacks}
renderAssistantHtml
retryContent=""
totalMessageCount={messages.length}
/>
)
expect(htmlRenderProbe).toHaveBeenCalledTimes(1)
expect(htmlRenderProbe).toHaveBeenCalledWith(
'<div>Complete HTML</div>'
)
})
it('renders only the immutably changed streaming row and retains unchanged DOM state', () => {
const messages = createMessages()
const props = {
+38 -4
View File
@@ -252,8 +252,10 @@ function ToolExecutionList({
}
const SubagentStatusCard = memo(function SubagentStatusCard({
renderHtml,
subagent
}: {
renderHtml: boolean
subagent: SubagentActivity
}): React.JSX.Element {
const { t } = useTranslation('app')
@@ -334,7 +336,13 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
<section>
<strong>{t('chat.subagents.output')}</strong>
<div className="markdown-content">
<MarkdownRenderer>{subagent.output}</MarkdownRenderer>
<MarkdownRenderer
renderHtml={
renderHtml && subagent.state === 'completed'
}
>
{subagent.output}
</MarkdownRenderer>
</div>
</section>
) : (
@@ -355,8 +363,10 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
})
function SubagentStatusList({
renderHtml,
subagents
}: {
renderHtml: boolean
subagents: SubagentActivity[]
}): React.JSX.Element {
const { t } = useTranslation('app')
@@ -369,6 +379,7 @@ function SubagentStatusList({
{subagents.map((subagent) => (
<SubagentStatusCard
key={subagent.childTaskId}
renderHtml={renderHtml}
subagent={subagent}
/>
))}
@@ -383,6 +394,7 @@ type ChatMessageRowProps = {
greeting: boolean
locale: TimeFormatLocale
message: Message
renderAssistantHtml?: boolean
onArticleRef: (messageId: string, element: HTMLElement | null) => void
onCopyMessage: (content: string) => Promise<void>
onDownloadImage: (item: ImageViewerItem) => void
@@ -416,6 +428,7 @@ function ChatMessageRowView({
greeting,
locale,
message,
renderAssistantHtml = false,
onArticleRef,
onCopyMessage,
onDownloadImage,
@@ -594,6 +607,7 @@ function ChatMessageRowView({
) : item.kind === 'subagents' ? (
<SubagentStatusList
key={item.id}
renderHtml={renderAssistantHtml}
subagents={item.childTaskIds.flatMap((childTaskId) => {
const subagent = subagentsById.get(childTaskId)
return subagent ? [subagent] : []
@@ -610,7 +624,15 @@ function ChatMessageRowView({
className="markdown-content message__content"
key={item.block.id}
>
<MarkdownRenderer>{item.block.content}</MarkdownRenderer>
<MarkdownRenderer
renderHtml={
renderAssistantHtml &&
message.role === 'assistant' &&
message.state === 'complete'
}
>
{item.block.content}
</MarkdownRenderer>
</div>
)
)}
@@ -626,7 +648,13 @@ function ChatMessageRowView({
)}
{message.content && (
<div className="markdown-content message__content">
<MarkdownRenderer>
<MarkdownRenderer
renderHtml={
renderAssistantHtml &&
message.role === 'assistant' &&
message.state === 'complete'
}
>
{greeting ? t('conversation.greeting') : message.content}
</MarkdownRenderer>
</div>
@@ -634,7 +662,10 @@ function ChatMessageRowView({
</>
)}
{unorderedSubagents && unorderedSubagents.length > 0 && (
<SubagentStatusList subagents={unorderedSubagents} />
<SubagentStatusList
renderHtml={renderAssistantHtml}
subagents={unorderedSubagents}
/>
)}
{message.artifactIds?.map((artifactId) => {
const candidate = artifactById.get(artifactId)
@@ -1089,6 +1120,7 @@ type ChatTimelineProps = {
) => Promise<void>
onRetry: (content: string) => void
onRevealEarlier: () => void
renderAssistantHtml?: boolean
retryContent?: string
totalMessageCount: number
}
@@ -1111,6 +1143,7 @@ export function ChatTimeline({
onRespondQuestion,
onRetry,
onRevealEarlier,
renderAssistantHtml = false,
retryContent,
totalMessageCount
}: ChatTimelineProps): React.JSX.Element {
@@ -1152,6 +1185,7 @@ export function ChatTimeline({
onRespondApproval={onRespondApproval}
onRespondQuestion={onRespondQuestion}
onRetry={onRetry}
renderAssistantHtml={renderAssistantHtml}
retryContent={retryContent}
/>
)
@@ -193,6 +193,7 @@ const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async (
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -209,6 +210,7 @@ beforeEach(() => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -1233,6 +1235,7 @@ describe('MagicNotesWorkspace', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -1268,6 +1271,7 @@ describe('MagicNotesWorkspace', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -1386,6 +1390,7 @@ describe('MagicNotesWorkspace', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
+136
View File
@@ -109,6 +109,142 @@ const ready = true
expect(container.querySelector('script')).not.toBeInTheDocument()
})
it('renders fenced HTML as an isolated static preview when enabled', () => {
render(
<MarkdownRenderer renderHtml>{`\`\`\`html
<!doctype html>
<html>
<head>
<style>body { color: navy; }</style>
<script>window.bad = true</script>
</head>
<body>
<h1>HTML dashboard</h1>
<img src="https://tracker.example/pixel.png" onerror="bad()">
<form action="https://example.com"><button>Send</button></form>
</body>
</html>
\`\`\``}</MarkdownRenderer>
)
const preview = screen.getByTitle('Agent 回复 HTML 静态预览')
const source = preview.getAttribute('srcdoc') ?? ''
expect(preview).toHaveAttribute('sandbox', '')
expect(preview).toHaveAttribute('referrerpolicy', 'no-referrer')
expect(source).toContain('HTML dashboard')
expect(source).toContain("default-src 'none'")
expect(source).not.toContain('<script')
expect(source).not.toContain('<form')
expect(source).not.toContain('https://tracker.example')
expect(
screen.getByText('静态安全预览,脚本和网络已禁用')
).toBeInTheDocument()
const sourceButton = screen.getByRole('button', {
name: '查看源码'
})
expect(sourceButton).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(sourceButton)
expect(screen.getByText(/window\.bad = true/u)).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '隐藏源码' })
).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(screen.getByRole('button', { name: '隐藏源码' }))
expect(
screen.queryByText(/window\.bad = true/u)
).not.toBeInTheDocument()
})
it('opens and closes the full-screen HTML preview', () => {
render(
<div className="app-shell">
<MarkdownRenderer renderHtml>{`\`\`\`html
<section><h1>Full screen page</h1></section>
\`\`\``}</MarkdownRenderer>
</div>
)
const viewerButton = screen.getByRole('button', {
name: '全屏预览'
})
viewerButton.focus()
fireEvent.click(viewerButton)
const dialog = screen.getByRole('dialog', {
name: 'HTML 全屏预览'
})
expect(dialog).toHaveAttribute('aria-modal', 'true')
const closeButton = screen.getByRole('button', {
name: '关闭全屏预览'
})
expect(closeButton).toHaveFocus()
expect(
document.querySelector<HTMLElement>('.app-shell')?.inert
).toBe(true)
const frame = screen.getByTitle('Agent 回复 HTML 全屏静态预览')
expect(frame).toHaveAttribute('sandbox', '')
expect(frame).toHaveAttribute('referrerpolicy', 'no-referrer')
expect(frame.getAttribute('srcdoc') ?? '').toContain(
'Full screen page'
)
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(viewerButton).toHaveFocus()
})
it('keeps author stylesheets and inline styles in the preview', () => {
render(
<MarkdownRenderer renderHtml>{`\`\`\`html
<!doctype html>
<html>
<head>
<style>
body { background: #101014; color: #f5f5f7; }
.hero { background-image: url(https://cdn.example/bg.png); }
@import url("https://fonts.example/inter.css");
</style>
</head>
<body>
<section class="hero" style="padding: 24px; background: url(https://cdn.example/b.png)">
Styled hero
</section>
</body>
</html>
\`\`\``}</MarkdownRenderer>
)
const source =
screen
.getByTitle('Agent 回复 HTML 静态预览')
.getAttribute('srcdoc') ?? ''
expect(source).toContain('background: #101014')
expect(source).toContain('.hero {')
expect(source).toContain('padding: 24px')
})
it('renders a complete HTML reply directly and keeps HTML as code when disabled', () => {
const source = '<html><body><main>Complete page</main></body></html>'
const { rerender } = render(
<MarkdownRenderer renderHtml>{source}</MarkdownRenderer>
)
expect(
screen.getByTitle('Agent 回复 HTML 静态预览')
).toHaveAttribute('sandbox', '')
rerender(
<MarkdownRenderer>{source}</MarkdownRenderer>
)
expect(
screen.queryByTitle('Agent 回复 HTML 静态预览')
).not.toBeInTheDocument()
expect(
screen.getByText(source, { exact: true })
).toBeInTheDocument()
})
it('renders a whole Markdown fence as formatted content', () => {
const { container } = render(
<MarkdownRenderer>{`\`\`\`markdown
+55 -5
View File
@@ -12,6 +12,7 @@ import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import type { Components } from 'react-markdown'
import { useTranslation } from 'react-i18next'
import { StaticHtmlPreview } from './StaticHtmlPreview'
const MermaidDiagram = lazy(() =>
import('./MermaidDiagram').then((module) => ({
@@ -34,7 +35,8 @@ const linkComponent: Components['a'] = ({
function markdownComponents(
tableAriaLabel: string,
mermaidLoadingLabel: string
mermaidLoadingLabel: string,
renderHtml: boolean
): Components {
return {
a: linkComponent,
@@ -70,6 +72,22 @@ function markdownComponents(
</Suspense>
)
}
if (
renderHtml &&
isValidElement<{
children?: React.ReactNode
className?: string
}>(child) &&
/(?:^|\s)language-html?(?:\s|$)/iu.test(
child.props.className ?? ''
)
) {
const source = String(child.props.children ?? '').replace(
/\n$/u,
''
)
return <StaticHtmlPreview source={source} />
}
return <pre {...properties}>{children}</pre>
},
table: ({ children, node, ...properties }) => {
@@ -90,6 +108,7 @@ function markdownComponents(
type MarkdownRendererProps = {
children: string
renderHtml?: boolean
}
const inlineMarkdownComponents: Components = {
@@ -213,18 +232,49 @@ function unwrapMarkdownFence(content: string): string {
return fencedMarkdown?.[1] ?? content
}
function standaloneHtmlSource(content: string): string | undefined {
const trimmed = content.trim()
if (
/^(?:<!doctype\s+html(?:\s[^>]*)?>\s*)?<html(?:\s|>)/iu.test(
trimmed
) ||
/^<[a-z][a-z0-9:-]*(?:\s[^<>]*?)?>[\s\S]*<\/[a-z][a-z0-9:-]*>\s*$/iu.test(
trimmed
)
) {
return trimmed
}
return undefined
}
export const MarkdownRenderer = memo(function MarkdownRenderer({
children
children,
renderHtml = false
}: MarkdownRendererProps): React.JSX.Element {
const { t } = useTranslation('app')
const normalizedContent = normalizeLatexDelimiters(
unwrapMarkdownFence(children)
)
const components = useMemo(
() =>
markdownComponents(
t('markdown.scrollableTable'),
t('markdown.mermaidLoading')
t('markdown.mermaidLoading'),
renderHtml
),
[t]
[renderHtml, t]
)
const standaloneHtml = standaloneHtmlSource(normalizedContent)
if (standaloneHtml) {
return renderHtml ? (
<StaticHtmlPreview source={standaloneHtml} />
) : (
<pre>
<code className="language-html">{standaloneHtml}</code>
</pre>
)
}
return (
<ReactMarkdown
@@ -245,7 +295,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
]}
skipHtml
>
{normalizeLatexDelimiters(unwrapMarkdownFence(children))}
{normalizedContent}
</ReactMarkdown>
)
})
@@ -44,6 +44,9 @@ import {
import { displayErrorMessage } from './error-message'
type PlatformFeaturesSettingsSectionProps = {
onConversationHtmlRenderingEnabledChange: (
enabled: boolean
) => void
onMagicNotesEnabledChange: (enabled: boolean) => void
onMagicNotesShowIncompleteTodoCountChange: (
enabled: boolean
@@ -74,6 +77,7 @@ const shortcutErrorTranslationKeys: Record<
}
export function PlatformFeaturesSettingsSection({
onConversationHtmlRenderingEnabledChange,
onMagicNotesEnabledChange,
onMagicNotesShowIncompleteTodoCountChange,
onRemoteProjectsEnabledChange,
@@ -486,6 +490,34 @@ export function PlatformFeaturesSettingsSection({
}
}
const changeConversationHtmlRendering = async (
enabled: boolean
): Promise<void> => {
const updates = window.goodbuddy.updates
if (!updates || !settings) {
return
}
setSaving(true)
setError(undefined)
try {
const nextSettings = await updates.updateSettings({
conversationHtmlRenderingEnabled: enabled
})
setSettings(nextSettings)
onConversationHtmlRenderingEnabledChange(
nextSettings.conversationHtmlRenderingEnabled
)
} catch {
setError(
t(
'platformFeatures.errors.saveConversationHtmlRenderingFailed'
)
)
} finally {
setSaving(false)
}
}
const changeRemoteProjects = async (
enabled: boolean
): Promise<void> => {
@@ -723,6 +755,36 @@ export function PlatformFeaturesSettingsSection({
</p>
)}
</article>
{settings && (
<article className="capability-card">
<div className="capability-card__header">
<div>
<strong>
{t('platformFeatures.conversationHtml.title')}
</strong>
<small>
{t('platformFeatures.conversationHtml.description')}
</small>
</div>
</div>
<label className="toggle-row">
<input
checked={settings.conversationHtmlRenderingEnabled}
disabled={saving}
onChange={(event) =>
void changeConversationHtmlRendering(
event.target.checked
)
}
role="switch"
type="checkbox"
/>
<span>
{t('platformFeatures.conversationHtml.enabled')}
</span>
</label>
</article>
)}
{settings ? (
<article className="capability-card">
<div className="capability-card__header">
+35
View File
@@ -546,6 +546,7 @@ let applicationSettings: ApplicationSettings = {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -836,6 +837,7 @@ describe('SettingsPanel runtime files', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -1612,6 +1614,39 @@ describe('SettingsPanel runtime files', () => {
).toBeInTheDocument()
})
it('disables conversation HTML rendering from General settings', async () => {
const onConversationHtmlRenderingEnabledChange = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
onConversationHtmlRenderingEnabledChange={
onConversationHtmlRenderingEnabledChange
}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const toggle = await screen.findByRole('switch', {
name: '在会话中渲染 HTML'
})
expect(toggle).toBeChecked()
fireEvent.click(toggle)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
conversationHtmlRenderingEnabled: false
})
)
expect(
onConversationHtmlRenderingEnabledChange
).toHaveBeenCalledWith(false)
expect(toggle).not.toBeChecked()
})
it('does not guess a model download source when settings fail to load', async () => {
getApplicationSettings.mockRejectedValueOnce(
new Error('read failed')
+7
View File
@@ -237,6 +237,9 @@ type SettingsPanelProps = {
) => boolean
magicNotesEnabled?: boolean
remoteProjectsEnabled?: boolean
onConversationHtmlRenderingEnabledChange?: (
enabled: boolean
) => void
onMagicNotesEnabledChange?: (enabled: boolean) => void
onRemoteProjectsEnabledChange?: (enabled: boolean) => void
onMagicNotesShowIncompleteTodoCountChange?: (
@@ -646,6 +649,7 @@ export function SettingsPanel({
onBrandingPreferencesChange = () => false,
magicNotesEnabled = false,
remoteProjectsEnabled = false,
onConversationHtmlRenderingEnabledChange = () => {},
onMagicNotesEnabledChange = () => {},
onRemoteProjectsEnabledChange = () => {},
onMagicNotesShowIncompleteTodoCountChange = () => {},
@@ -2255,6 +2259,9 @@ export function SettingsPanel({
{activeTab === 'platform-features' && (
<PlatformFeaturesSettingsSection
onDirtyChange={setPlatformFeaturesDirty}
onConversationHtmlRenderingEnabledChange={
onConversationHtmlRenderingEnabledChange
}
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
onMagicNotesShowIncompleteTodoCountChange={
onMagicNotesShowIncompleteTodoCountChange
+255
View File
@@ -0,0 +1,255 @@
import DOMPurify from 'dompurify'
import { Code2, FileCode2, Maximize2, X } from 'lucide-react'
import {
memo,
useEffect,
useId,
useMemo,
useRef,
useState
} from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
const forbiddenTags = [
'base',
'embed',
'form',
'iframe',
'link',
'meta',
'object',
'script'
]
const forbiddenAttributes = [
'action',
'formaction',
'ping',
'srcset'
]
const staticPreviewPolicy = [
"default-src 'none'",
"base-uri 'none'",
"connect-src 'none'",
'font-src data:',
"form-action 'none'",
"frame-src 'none'",
'img-src data:',
"media-src 'none'",
"object-src 'none'",
"script-src 'none'",
"style-src 'unsafe-inline'"
].join('; ')
const safeInlineImagePattern =
/^data:image\/(?:gif|jpeg|png|webp);base64,[a-z0-9+/=\s]+$/iu
function createStaticHtmlDocument(source: string): string {
const sanitized = DOMPurify.sanitize(source, {
ALLOW_UNKNOWN_PROTOCOLS: false,
FORBID_ATTR: forbiddenAttributes,
FORBID_TAGS: forbiddenTags,
WHOLE_DOCUMENT: true
})
const documentNode = new DOMParser().parseFromString(
sanitized,
'text/html'
)
for (const element of documentNode.querySelectorAll('*')) {
for (const attribute of [...element.attributes]) {
const name = attribute.name.toLowerCase()
if (name.startsWith('on')) {
element.removeAttribute(attribute.name)
continue
}
if (name === 'href' || name === 'xlink:href') {
if (!attribute.value.trim().startsWith('#')) {
element.removeAttribute(attribute.name)
}
continue
}
if (name === 'src' || name === 'poster') {
if (
element.localName !== 'img' ||
!safeInlineImagePattern.test(attribute.value.trim())
) {
element.removeAttribute(attribute.name)
}
}
}
}
const policy = documentNode.createElement('meta')
policy.httpEquiv = 'Content-Security-Policy'
policy.content = staticPreviewPolicy
documentNode.head.prepend(policy)
const viewport = documentNode.createElement('meta')
viewport.name = 'viewport'
viewport.content = 'width=device-width, initial-scale=1'
documentNode.head.append(viewport)
return `<!doctype html>\n${documentNode.documentElement.outerHTML}`
}
function StaticHtmlViewer({
documentSource,
onClose
}: {
documentSource: string
onClose: () => void
}): React.JSX.Element {
const { t } = useTranslation('app')
const titleId = useId()
const hintId = useId()
const dialogRef = useRef<HTMLElement>(null)
const closeRef = useRef<HTMLButtonElement>(null)
useEffect(
() => activateModalFocus(() => closeRef.current),
[]
)
return createPortal(
<div
className="html-preview-viewer-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose()
}
}}
>
<section
aria-describedby={hintId}
aria-labelledby={titleId}
aria-modal="true"
className="html-preview-viewer"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
trapTabFocus(event, dialogRef.current)
}}
ref={dialogRef}
role="dialog"
>
<header className="html-preview-viewer__header">
<div>
<strong id={titleId}>
{t('markdown.htmlViewerTitle')}
</strong>
<small id={hintId}>
{t('markdown.htmlPreviewNotice')}
</small>
</div>
<button
aria-label={t('markdown.htmlCloseViewer')}
className="icon-button"
onClick={onClose}
ref={closeRef}
title={t('markdown.htmlCloseViewer')}
type="button"
>
<X aria-hidden="true" size={18} />
</button>
</header>
<iframe
referrerPolicy="no-referrer"
sandbox=""
srcDoc={documentSource}
title={t('markdown.htmlViewerFrame')}
/>
</section>
</div>,
document.body
)
}
export const StaticHtmlPreview = memo(function StaticHtmlPreview({
source
}: {
source: string
}): React.JSX.Element {
const { t } = useTranslation('app')
const sourceId = useId()
// Tracking the source these panels were opened for resets both when the
// reply changes, without an effect that cascades renders.
const [visibleSource, setVisibleSource] = useState<string>()
const [viewerSource, setViewerSource] = useState<string>()
const sourceVisible = visibleSource === source
const viewerOpen = viewerSource === source
const documentSource = useMemo(
() => createStaticHtmlDocument(source),
[source]
)
const sourceLabel = t(
sourceVisible
? 'markdown.htmlHideSource'
: 'markdown.htmlViewSource'
)
return (
<figure className="message-html-preview">
<figcaption className="message-html-preview__header">
<span>
<FileCode2 aria-hidden="true" size={15} />
<span>
<strong>{t('markdown.htmlPreview')}</strong>
<small>{t('markdown.htmlPreviewNotice')}</small>
</span>
</span>
<div
aria-label={t('markdown.htmlActions')}
className="message-html-preview__actions"
role="group"
>
<button
aria-controls={sourceId}
aria-expanded={sourceVisible}
aria-label={sourceLabel}
className={`icon-button${
sourceVisible ? ' icon-button--active' : ''
}`}
onClick={() =>
setVisibleSource(sourceVisible ? undefined : source)
}
title={sourceLabel}
type="button"
>
<Code2 aria-hidden="true" size={17} />
</button>
<button
aria-label={t('markdown.htmlOpenViewer')}
className="icon-button"
onClick={() => setViewerSource(source)}
title={t('markdown.htmlOpenViewer')}
type="button"
>
<Maximize2 aria-hidden="true" size={17} />
</button>
</div>
</figcaption>
<iframe
loading="lazy"
referrerPolicy="no-referrer"
sandbox=""
srcDoc={documentSource}
title={t('markdown.htmlPreviewFrame')}
/>
{sourceVisible && <pre id={sourceId}>{source}</pre>}
{viewerOpen && (
<StaticHtmlViewer
documentSource={documentSource}
onClose={() => setViewerSource(undefined)}
/>
)}
</figure>
)
})
@@ -25,6 +25,7 @@ describe('UpdateSettingsSection', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
+13 -1
View File
@@ -703,6 +703,18 @@ export const app = {
mermaidZoomIn: 'Zoom in diagram',
mermaidResetZoom: 'Reset zoom',
mermaidZoomLevel: 'Current zoom level',
mermaidCloseViewer: 'Close large Mermaid diagram'
mermaidCloseViewer: 'Close large Mermaid diagram',
htmlPreview: 'HTML preview',
htmlPreviewNotice:
'Static safe preview with scripts and network access disabled',
htmlPreviewFrame: 'Static HTML preview from the Agent reply',
htmlActions: 'HTML preview actions',
htmlViewSource: 'View source',
htmlHideSource: 'Hide source',
htmlOpenViewer: 'Full-screen preview',
htmlViewerTitle: 'Full-screen HTML preview',
htmlViewerFrame:
'Full-screen static HTML preview from the Agent reply',
htmlCloseViewer: 'Close full-screen preview'
}
} satisfies TranslationShape<typeof chineseApp>
@@ -540,6 +540,8 @@ export const settingsSections = {
serviceUnavailable:
'Application settings are not available in this version',
readFailed: 'Could not load platform feature settings',
saveConversationHtmlRenderingFailed:
'Could not save the conversation HTML rendering setting. Try again.',
saveRemoteProjectsFailed:
'Could not save the Remote Projects setting. Try again.',
saveMagicNotesFailed: 'Could not save Magic Notes settings. Try again.',
@@ -558,6 +560,12 @@ export const settingsSections = {
general: 'General',
magicNotes: 'Magic Notes'
},
conversationHtml: {
title: 'Conversation HTML rendering',
description:
'Shows complete HTML documents and HTML code blocks in Agent replies as static previews. Previews cannot run scripts, access the network, submit forms, or open windows.',
enabled: 'Render HTML in conversations'
},
remoteProjects: {
title: 'Remote Projects (Technical Preview)',
description:
+11 -1
View File
@@ -647,6 +647,16 @@ export const app = {
mermaidZoomIn: '放大图表',
mermaidResetZoom: '重置缩放',
mermaidZoomLevel: '当前缩放比例',
mermaidCloseViewer: '关闭 Mermaid 大图'
mermaidCloseViewer: '关闭 Mermaid 大图',
htmlPreview: 'HTML 预览',
htmlPreviewNotice: '静态安全预览,脚本和网络已禁用',
htmlPreviewFrame: 'Agent 回复 HTML 静态预览',
htmlActions: 'HTML 预览操作',
htmlViewSource: '查看源码',
htmlHideSource: '隐藏源码',
htmlOpenViewer: '全屏预览',
htmlViewerTitle: 'HTML 全屏预览',
htmlViewerFrame: 'Agent 回复 HTML 全屏静态预览',
htmlCloseViewer: '关闭全屏预览'
}
} as const
@@ -515,6 +515,8 @@ export const settingsSections = {
errors: {
serviceUnavailable: '当前版本未提供应用设置服务',
readFailed: '读取平台功能设置失败',
saveConversationHtmlRenderingFailed:
'保存会话 HTML 渲染设置失败,请重试',
saveRemoteProjectsFailed: '保存远程项目设置失败,请重试',
saveMagicNotesFailed: '保存魔法笔记设置失败,请重试',
saveIncompleteTodoCountFailed:
@@ -529,6 +531,12 @@ export const settingsSections = {
general: '通用设置',
magicNotes: '魔法笔记'
},
conversationHtml: {
title: '会话 HTML 渲染',
description:
'将 Agent 回复中的完整 HTML 或 HTML 代码块直接显示为静态预览。预览不会执行脚本、联网、提交表单或打开窗口。',
enabled: '在会话中渲染 HTML'
},
remoteProjects: {
title: '远程项目(技术预览)',
description: '启用 SSH Host 管理和由 GoodBuddy 托管的远程项目。',
+120
View File
@@ -5598,6 +5598,126 @@ button > svg {
margin: 0;
}
.message-html-preview {
display: grid;
min-width: 0;
margin: 0;
gap: var(--space-2);
}
.message-html-preview__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.message-html-preview__header > span {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.message-html-preview__header > span > span {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.message-html-preview__header small {
color: var(--text-secondary);
}
.message-html-preview__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-1);
}
.message-html-preview > iframe {
width: 100%;
height: 480px;
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
}
.message-html-preview > pre {
max-height: 480px;
margin: 0;
overflow: auto;
}
.html-preview-viewer-backdrop {
position: fixed;
z-index: 120;
display: grid;
padding: var(--space-4);
background: var(--overlay-backdrop);
inset: 38px 0 0;
place-items: center;
}
.html-preview-viewer {
display: grid;
width: min(1280px, 100%);
height: min(860px, calc(100vh - 70px));
min-height: 0;
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
grid-template-rows: auto minmax(0, 1fr);
}
.html-preview-viewer__header {
display: flex;
min-width: 0;
align-items: center;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-subtle);
justify-content: space-between;
gap: var(--space-3);
}
.html-preview-viewer__header > div:first-child {
display: grid;
min-width: 0;
gap: 2px;
}
.html-preview-viewer__header strong {
color: var(--text-primary);
font-size: var(--font-section-title);
}
.html-preview-viewer__header small {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.4;
}
.html-preview-viewer > iframe {
width: 100%;
height: 100%;
min-height: 0;
border: 0;
border-radius: 0 0 var(--radius-card) var(--radius-card);
background: var(--surface-raised);
}
@media (max-width: 720px) {
.html-preview-viewer-backdrop {
padding: var(--space-2);
}
.html-preview-viewer {
height: calc(100vh - 54px);
}
}
.markdown-content > :first-child {
margin-top: 0;
}
@@ -36,6 +36,7 @@ const applicationPreferencesSchema = z
updateSource: updateSourceSchema,
modelDownloadSource: modelDownloadSourceSchema,
localToolEnvironment: localToolEnvironmentSettingsSchema,
conversationHtmlRenderingEnabled: z.boolean(),
remoteProjectsEnabled: z.boolean(),
magicNotesEnabled: z.boolean(),
magicNotesShowIncompleteTodoCount: z.boolean(),
@@ -137,6 +137,7 @@ describe('GoodBuddy configuration contracts', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,