From a4b64a1641addb3c66e2d8f909a1e447d9c1bea3 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 5 Sep 2026 09:10:44 +0800 Subject: [PATCH] feat: preview Agent HTML in conversations and unblock long replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 现在可直接在会话内预览,支持查看源码和全屏预览,默认开启并可在“平台功能 / 通用设置”关闭;直连模型的长回复不再因大小限制中断;上一次运行失败或被中断后,新发送的消息会立即执行而不再排队等待。 --- FEATURES.md | 10 +- FEATURES.zh-CN.md | 2 +- UI-DESIGN.md | 10 + .../conversation-html-rendering/README.md | 29 ++ .../conversation-html-rendering/prd.md | 69 +++++ .../conversation-html-rendering/progress.md | 39 +++ .../technical-design.md | 68 +++++ docs/features/direct-model-agent/progress.md | 16 +- .../direct-model-agent/technical-design.md | 6 +- .../task-and-job/scheduled-task-prd.md | 8 +- src/main/agent/model-runtime.test.ts | 167 +++++++----- src/main/agent/model-runtime.ts | 126 +++------ src/main/application-settings-store.test.ts | 78 +++++- src/main/application-settings-store.ts | 48 +++- src/main/ipc.test.ts | 215 +++++++++++++++ src/main/ipc.ts | 37 ++- src/renderer/src/App.test.tsx | 93 +++++++ src/renderer/src/App.tsx | 16 ++ src/renderer/src/ChatTimeline.test.tsx | 60 ++++- src/renderer/src/ChatTimeline.tsx | 42 ++- src/renderer/src/MagicNotesWorkspace.test.tsx | 5 + src/renderer/src/MarkdownRenderer.test.tsx | 136 ++++++++++ src/renderer/src/MarkdownRenderer.tsx | 60 ++++- .../src/PlatformFeaturesSettingsSection.tsx | 62 +++++ src/renderer/src/SettingsPanel.test.tsx | 35 +++ src/renderer/src/SettingsPanel.tsx | 7 + src/renderer/src/StaticHtmlPreview.tsx | 255 ++++++++++++++++++ .../src/UpdateSettingsSection.test.tsx | 1 + src/renderer/src/i18n/locales/en-US/app.ts | 14 +- .../i18n/locales/en-US/settingsSections.ts | 8 + src/renderer/src/i18n/locales/zh-CN/app.ts | 12 +- .../i18n/locales/zh-CN/settingsSections.ts | 8 + src/renderer/src/styles.css | 120 +++++++++ src/shared/application-settings-contracts.ts | 1 + src/shared/goodbuddy-config-contracts.test.ts | 1 + 35 files changed, 1669 insertions(+), 195 deletions(-) create mode 100644 docs/features/conversation-html-rendering/README.md create mode 100644 docs/features/conversation-html-rendering/prd.md create mode 100644 docs/features/conversation-html-rendering/progress.md create mode 100644 docs/features/conversation-html-rendering/technical-design.md create mode 100644 src/renderer/src/StaticHtmlPreview.tsx diff --git a/FEATURES.md b/FEATURES.md index 7d482ed9..fc98e9ea 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index 26fb06e3..ca4804f1 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -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、模型、审批策略、预算和超时,并支持模板复用。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index e8189515..e3ac40ef 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -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、焦点恢复和窄窗口布局。 diff --git a/docs/features/conversation-html-rendering/README.md b/docs/features/conversation-html-rendering/README.md new file mode 100644 index 00000000..b62f0c82 --- /dev/null +++ b/docs/features/conversation-html-rendering/README.md @@ -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)负责。 diff --git a/docs/features/conversation-html-rendering/prd.md b/docs/features/conversation-html-rendering/prd.md new file mode 100644 index 00000000..21c147a8 --- /dev/null +++ b/docs/features/conversation-html-rendering/prd.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 不进入可执行预览。 diff --git a/docs/features/conversation-html-rendering/progress.md b/docs/features/conversation-html-rendering/progress.md new file mode 100644 index 00000000..7f5af733 --- /dev/null +++ b/docs/features/conversation-html-rendering/progress.md @@ -0,0 +1,39 @@ +# 会话 HTML 渲染实现进度 + +## 已实现 + +- [x] 应用级持久开关,默认开启,并提供旧设置迁移。 +- [x] 平台功能通用设置中的共享 Switch。 +- [x] 已完成 Agent 回复和子 Agent 输出中的完整 HTML 与 fenced HTML 识别。 +- [x] 隔离静态预览、持续安全说明和源码切换。 +- [x] 预览保留作者 ` + + + +

HTML dashboard

+ +
+ + +\`\`\``} + ) + + 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(' { + render( +
+ {`\`\`\`html +

Full screen page

+\`\`\``}
+
+ ) + + 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('.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( + {`\`\`\`html + + + + + + +
+ Styled hero +
+ + +\`\`\``}
+ ) + + 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 = '
Complete page
' + const { rerender } = render( + {source} + ) + + expect( + screen.getByTitle('Agent 回复 HTML 静态预览') + ).toHaveAttribute('sandbox', '') + + rerender( + {source} + ) + 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( {`\`\`\`markdown diff --git a/src/renderer/src/MarkdownRenderer.tsx b/src/renderer/src/MarkdownRenderer.tsx index cb7846b8..66240c27 100644 --- a/src/renderer/src/MarkdownRenderer.tsx +++ b/src/renderer/src/MarkdownRenderer.tsx @@ -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( ) } + 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 + } return
{children}
}, 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 ( + /^(?:]*)?>\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 ? ( + + ) : ( +
+        {standaloneHtml}
+      
+ ) + } return ( - {normalizeLatexDelimiters(unwrapMarkdownFence(children))} + {normalizedContent} ) }) diff --git a/src/renderer/src/PlatformFeaturesSettingsSection.tsx b/src/renderer/src/PlatformFeaturesSettingsSection.tsx index 9c5e8cc5..cafe0008 100644 --- a/src/renderer/src/PlatformFeaturesSettingsSection.tsx +++ b/src/renderer/src/PlatformFeaturesSettingsSection.tsx @@ -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 => { + 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 => { @@ -723,6 +755,36 @@ export function PlatformFeaturesSettingsSection({

)} + {settings && ( +
+
+
+ + {t('platformFeatures.conversationHtml.title')} + + + {t('platformFeatures.conversationHtml.description')} + +
+
+ +
+ )} {settings ? (
diff --git a/src/renderer/src/SettingsPanel.test.tsx b/src/renderer/src/SettingsPanel.test.tsx index 86ef27b0..c236a0ee 100644 --- a/src/renderer/src/SettingsPanel.test.tsx +++ b/src/renderer/src/SettingsPanel.test.tsx @@ -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( + {})} + 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') diff --git a/src/renderer/src/SettingsPanel.tsx b/src/renderer/src/SettingsPanel.tsx index 5e89a057..b883b85f 100644 --- a/src/renderer/src/SettingsPanel.tsx +++ b/src/renderer/src/SettingsPanel.tsx @@ -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' && ( \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(null) + const closeRef = useRef(null) + + useEffect( + () => activateModalFocus(() => closeRef.current), + [] + ) + + return createPortal( +
{ + if (event.target === event.currentTarget) { + onClose() + } + }} + > +
{ + if (event.key === 'Escape') { + event.preventDefault() + onClose() + return + } + trapTabFocus(event, dialogRef.current) + }} + ref={dialogRef} + role="dialog" + > +
+
+ + {t('markdown.htmlViewerTitle')} + + + {t('markdown.htmlPreviewNotice')} + +
+ +
+