diff --git a/FEATURES.md b/FEATURES.md index 1947e0ba..e7cd4b20 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -106,6 +106,8 @@ records are listed separately and do not introduce another feature status. stash or discard. Managed SSH operations require Agent `0.11.23`. - [x] **Rich responses**: Supports GitHub Flavored Markdown, LaTeX math, constrained Mermaid diagrams, and static in-conversation HTML previews. + Mermaid's large-diagram viewer keeps all edges reachable when zoomed and + exports the complete diagram as PNG, independently of zoom and pan. 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 diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index 6ed3a8d2..031a047b 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -30,7 +30,7 @@ - [x] **HTTP OCR 与附件持久预览**:在“设置 / 文档解析”配置 HTTP PaddleOCR-VL,供聊天文档和知识库导入共用。保存原件与插图,提供正文/图片/详情预览、显式图片文字提取,以及选图加入指定会话草稿。普通图片默认走图片输入,发送与入队检查实际模型和 Runtime。Windows、直连视觉输入和共享 Linux Host 托管 OpenCode 已有真实验证;部署高级 OCR 模型及其他 Runtime 组合需分别测试。本机推理监控只管理本地服务,不新增应用中心入口,详见[范围与证据](./docs/features/document-processing/README.md)。 - [x] **工作区单文件差异**:变更文件可分别查看已暂存和未暂存差异,覆盖删除、重命名和未跟踪文件;刷新会重读当前差异、当前浏览目录和已展开目录,超过 50 项变更可继续加载。 - [x] **工作区文件与 Git 管理**:支持文件和目录的新建、重命名、移动、删除、属性查看,以及本地/远程分支搜索与切换、新建分支、显式 Fetch、提交历史和单文件提交差异。工具按文件/Git 视图分组,分支搜索使用统一紧凑表单,子目录保留文字导航而根目录不再显示孤立图标。提交历史贴底,首次展开占 Git 内容区一半,支持调整比例、独立滚动和统一刷新;切换项目自动检测 Git,不沿用其他项目状态。移动限于工作区内,删除须确认,分支冲突不自动 stash 或丢弃改动;托管 SSH 操作需要 Agent `0.11.23`。 -- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式、受控 Mermaid 图表和会话内 HTML 静态预览。Agent 完成回复后,完整 HTML 与 HTML 代码块可在原位置预览,并通过图标按钮查看源码或打开全屏预览;该能力默认开启,可在“平台功能 / 通用设置”关闭,预览不执行脚本、联网、表单提交或窗口操作。 +- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式、受控 Mermaid 图表和会话内 HTML 静态预览。Mermaid 大图放大后可滚动到各个边缘,并可导出完整 PNG,不受当前缩放和拖动位置影响。Agent 完成回复后,完整 HTML 与 HTML 代码块可在原位置预览,并通过图标按钮查看源码或打开全屏预览;该能力默认开启,可在“平台功能 / 通用设置”关闭,预览不执行脚本、联网、表单提交或窗口操作。 - [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。 - [x] **助手工作栏、多终端与可调布局**:工作栏开关位于明暗主题按钮旁;右侧工作栏使用持久“+”能力目录和应用 Tab,任务中心与工作区为不可关闭的单实例,浏览器可打开多个独立实例。任务中心可切换当前项目和所有项目,范围随布局保存;无项目任务在所有项目中标注“未绑定项目”。用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记编辑器的 AI 评论栏支持鼠标与键盘调宽;AI 评论栏记住显示状态及宽度,窄窗口下移至编辑器底部。 - [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 9364dcfd..23a25f70 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -642,6 +642,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 全屏预览遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape 关闭和焦点恢复规则, 并沿用同一套清理与 Sandbox/CSP 边界。关闭平台功能后必须显示 HTML 源码,不能隐藏内容 或保留旧预览。 +- Mermaid 大图支持缩放和拖动;图形超过画布时,四个边缘都必须能通过滚动到达,不能因居中而裁掉顶部或左侧。 + 弹窗工具栏提供“导出 PNG”,保存完整图形,不受当前缩放和滚动位置影响;背景使用当前主题的画布底色。 + 导出按原图四周留出 `16px`,以 2 倍分辨率绘制,任一边最多 `4096px`。导出期间禁用重复点击,失败在按钮旁提示重试。 - 本地会话的更多操作提供“在新会话中继续”。创建前必须先持久化当前稳定消息;有活动运行 或待发送内容时不可创建。新会话复制聊天内容与当前 Runtime/知识检索选择,重置压缩状态, 使用新的会话和消息 ID,且不复制 Task、队列、运行、记忆或成果归属。 diff --git a/docs/quality/recurring-defects.md b/docs/quality/recurring-defects.md index bdf1857f..9daed069 100644 --- a/docs/quality/recurring-defects.md +++ b/docs/quality/recurring-defects.md @@ -20,7 +20,7 @@ [UI 设计系统](../../UI-DESIGN.md)。 **防复发**:`WorkspacePrimitives.test.tsx` 的 -"releases the native drag region for every full-window overlay" 从 `styles.css` 自动发现 +"releases the native drag region for every full-window overlay" 从 Renderer 顶层全部 CSS 文件自动发现 全部 `position: fixed` 遮罩并逐个校验,新增遗漏会直接失败。 **历史**:`e9679fe`、`ca4a58c` 各修了一部分被人工点到的弹窗,同一批改动又新增了两个未覆盖 diff --git a/src/renderer/src/MarkdownRenderer.test.tsx b/src/renderer/src/MarkdownRenderer.test.tsx index 49fdfdd8..cab7ed03 100644 --- a/src/renderer/src/MarkdownRenderer.test.tsx +++ b/src/renderer/src/MarkdownRenderer.test.tsx @@ -464,6 +464,42 @@ flowchart LR ).toBe(false) }) + it('releases SVG URLs after a failed PNG export and allows retry', async () => { + const createObjectURL = vi.fn(() => 'blob:mermaid-export') + const revokeObjectURL = vi.fn() + let rejectDecode: (reason: Error) => void = () => undefined + const decode = vi.fn(() => new Promise((_resolve, reject) => { + rejectDecode = reject + })) + vi.stubGlobal('URL', class extends URL { + static createObjectURL = createObjectURL + static revokeObjectURL = revokeObjectURL + }) + vi.stubGlobal('Image', class { + src = '' + decode = decode + }) + try { + render({'```mermaid\nflowchart LR\nA --> B\n```'}) + fireEvent.click(await screen.findByRole('button', { name: '打开大图' })) + const button = screen.getByRole('button', { name: '导出 PNG' }) + fireEvent.click(button) + expect(button).toBeDisabled() + expect(button).toHaveAttribute('aria-busy', 'true') + await act(async () => rejectDecode(new Error('Decode failed'))) + expect(button).toBeEnabled() + expect(button).toHaveAccessibleDescription('导出失败,请点击“导出 PNG”重试。') + expect(revokeObjectURL).toHaveBeenCalledWith('blob:mermaid-export') + fireEvent.click(button) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(decode).toHaveBeenCalledTimes(2) + await act(async () => rejectDecode(new Error('Decode failed again'))) + expect(revokeObjectURL).toHaveBeenCalledTimes(2) + } finally { + vi.unstubAllGlobals() + } + }) + it('closes Mermaid controls when streamed source changes', async () => { const { rerender } = render( {`\`\`\`mermaid diff --git a/src/renderer/src/MermaidDiagram.tsx b/src/renderer/src/MermaidDiagram.tsx index 53cdda94..662e876f 100644 --- a/src/renderer/src/MermaidDiagram.tsx +++ b/src/renderer/src/MermaidDiagram.tsx @@ -1,6 +1,7 @@ import DOMPurify from 'dompurify' import { Code2, + Download, Maximize2, RotateCcw, X, @@ -247,6 +248,55 @@ function clampViewerZoom(zoom: number): number { return Math.min(MAX_VIEWER_ZOOM, Math.max(MIN_VIEWER_ZOOM, zoom)) } +async function downloadMermaidPng(svg: string, surface: HTMLElement): Promise { + const root = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement + const width = Number.parseFloat(root.getAttribute('width') ?? '') + const height = Number.parseFloat(root.getAttribute('height') ?? '') + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + throw new Error('Invalid diagram dimensions') + } + const styles = getComputedStyle(surface) + root.style.color = styles.color + const sourceUrl = URL.createObjectURL(new Blob( + [new XMLSerializer().serializeToString(root)], + { type: 'image/svg+xml;charset=utf-8' } + )) + try { + const image = new Image() + image.src = sourceUrl + await image.decode() + const padding = 16 + const scale = Math.min(2, MAX_MERMAID_DIMENSION / (width + padding * 2), + MAX_MERMAID_DIMENSION / (height + padding * 2)) + const canvas = document.createElement('canvas') + canvas.width = Math.ceil((width + padding * 2) * scale) + canvas.height = Math.ceil((height + padding * 2) * scale) + const context = canvas.getContext('2d') + if (!context) throw new Error('Canvas is unavailable') + context.fillStyle = styles.backgroundColor + context.fillRect(0, 0, canvas.width, canvas.height) + context.scale(scale, scale) + context.drawImage(image, padding, padding, width, height) + const png = await new Promise((resolve, reject) => { + canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error('PNG encoding failed')), 'image/png') + }) + const downloadUrl = URL.createObjectURL(png) + const link = document.createElement('a') + link.href = downloadUrl + link.download = 'mermaid-diagram.png' + document.body.append(link) + try { + link.click() + } finally { + link.remove() + // Keep the URL alive until Chromium has started the download. + window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1_000) + } + } finally { + URL.revokeObjectURL(sourceUrl) + } +} + function MermaidViewer({ onClose, svg @@ -254,7 +304,9 @@ function MermaidViewer({ const { i18n, t } = useTranslation('app') const titleId = useId() const hintId = useId() + const exportErrorId = useId() const dialogRef = useRef(null) + const canvasRef = useRef(null) const closeRef = useRef(null) const dragRef = useRef<{ pointerId: number @@ -265,6 +317,8 @@ function MermaidViewer({ } | undefined>(undefined) const [dragging, setDragging] = useState(false) const [zoom, setZoom] = useState(1) + const [exporting, setExporting] = useState(false) + const [exportFailed, setExportFailed] = useState(false) const pendingWheelZoomRef = useRef(0) const zoomFrameRef = useRef(undefined) const percentFormatter = useMemo( @@ -387,6 +441,29 @@ function MermaidViewer({ >