From 4ec2638d38474508a5d953792d0e72a539006296 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 3 Aug 2026 11:34:35 +0800 Subject: [PATCH] feat(web): embed interactive HTML artifacts --- CHANGELOG.md | 4 ++ PROGRESS.md | 3 +- core/__init__.py | 2 +- tests/frontend_preview.test.mjs | 36 +++++++++++--- web/static/dev.html | 24 +++++++++- web/static/js/chat.js | 16 +++++-- web/static/js/media.js | 82 ++++++++++++++++++++++++++++---- web/static/js/preview.js | 12 ++--- web/static/js/preview_content.js | 22 +++++++-- 9 files changed, 167 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0c6077..22db6b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.60.25 — 2026-08-03 + +- HTML 产物现在可以在对话中直接显示,进入可视区域后自动加载交互内容,也可放大到弹窗查看;HTML 与 Markdown 预览均可在渲染结果和源文件之间切换,常见 HTTPS 图表、地图及网页资源可正常加载。 + ## 0.60.24 — 2026-08-03 - 对话中的 Mermaid 流程图、柱状图和折线图现在可以直接显示;图表语法有误时会保留源码并给出提示。另修复嵌套代码块格式异常时后续正文整段变成代码的问题。 diff --git a/PROGRESS.md b/PROGRESS.md index e80bf3d..54100cd 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-08-03(Web Mermaid 直出 + Markdown 围栏容错,bump 0.60.24) +最后更新:2026-08-03(交互式 HTML 预览 + 对话内嵌,bump 0.60.25) --- @@ -23,6 +23,7 @@ ### 2026-08-03 +- **08-03 / 0.60.25 / 交互式 HTML 预览 + 对话内嵌**:文件预览将 HTML 从普通源码提升为可切换“预览 / 源文件”的 sandbox iframe,允许脚本与 HTTPS CDN/接口但保持 opaque origin,禁止宿主权限、表单和顶层跳转;助手最终答复中的 HTML 产物改为进入可视区才加载的内嵌卡片,并可放大复用完整预览,Markdown 同步补源文件切换。Node 14 项、Python 27 项、JavaScript 语法及 diff 检查通过;当前环境无可用浏览器实例,真实页面点击/截图留部署后冒烟;无 schema、migration、HTTP API 或依赖变化。 - **08-03 / 0.60.24 / Web Mermaid 直出 + Markdown 围栏容错**:模型偶发用同长度围栏嵌套 Markdown/Mermaid 示例,CommonMark 会把后续正文吞进未闭合代码块;新增仅针对该明确形态的前后端确定性修复,提示词统一要求外层使用更长异类围栏,历史上下文加载时同样修正且不批量回写生产数据。聊天页本地 vendoring Mermaid 11.16.0,仅在助手文字段定稿后顺序渲染 `language-mermaid`,采用 strict 安全级别、文本/边数上限,语法错误或组件不可用时保留源码并提示;真实 Edge 冒烟确认中文流程图与 XYChart 柱线组合图可生成 SVG。Python 27 项、Node 9 项、Ruff、JS/Python 语法及 diff 检查通过;无 schema、migration、HTTP API 或 Python 依赖变化。 - **08-03 / 0.60.23 / Office→PDF 组件感知 + 展示路径兼容**:生产 task `92ac20cf` 暴露两层问题:上传消息给出 user-root 相对的 `测试pdf/x.doc`,host tool 又按 task_dir 拼接导致首次找不到;改用裸文件名后,host 仅安装 `libreoffice-impress` 却因只检测 `soffice` 而错误宣称支持 DOC,最终 Writer 导入返回 `source file could not be loaded`。现 `office_to_pdf` 同时解析 task 相对、user-root 展示路径与 `/workspace` 路径;Debian/Ubuntu 按 Writer/Calc/Impress 实际安装包缓存支持后缀,工具 schema 只声明可用格式,缺组件在启动转换前给出明确管理员提示。RUN bootstrap 与故障表同步要求 host 安装三组件;相关 30 项 unittest、Python 编译及 diff 格式检查通过,本机无 LibreOffice,真实 `.doc/.xlsx/.pptx` 冒烟留部署 host;无 schema、migration、HTTP API 或 Python 依赖变化,无需重建沙箱镜像。 - **08-03 / 0.60.22 / 通用 PDF 路由修复 + host Office 转换**:复盘生产 task 发现通用 system prompt 只展示 `report --format docx`,模型遂误判平台渲染器只能出 Word,进而在 Docker 内探测 LibreOffice、临时安装 WeasyPrint 并手写转换脚本;现补齐 `report --format pdf` 唯一入口,新写 Markdown 报告直接由沙盒 Chromium 出 PDF。新增按 host `soffice` 实际可用性注册的 `office_to_pdf` typed tool,已有 DOCX/PPTX/XLSX/ODF 文件通过 backend host LibreOffice 转换,复用 `/workspace`→user_root 路径翻译并强制用户目录边界,Docker 内不再探测宿主命令。相关 34 项 unittest、Python 编译及 diff 格式检查通过;本机未安装 LibreOffice,真实 soffice 冒烟留部署 host 执行;无 schema、migration、HTTP API 或依赖变化,新增 host 工具无需重建沙箱镜像。 diff --git a/core/__init__.py b/core/__init__.py index 682cd5c..4cb7cbb 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.60.24" +__version__ = "0.60.25" diff --git a/tests/frontend_preview.test.mjs b/tests/frontend_preview.test.mjs index f3de931..47beb23 100644 --- a/tests/frontend_preview.test.mjs +++ b/tests/frontend_preview.test.mjs @@ -2,27 +2,37 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { htmlPreviewDocument } from "../web/static/js/preview_content.js"; +import { + HTML_PREVIEW_CSP, + configureHtmlPreviewFrame, + htmlPreviewDocument, +} from "../web/static/js/preview_content.js"; // preview.js has browser-only top-level bindings, so exercise the integration contract // through its shipped source and markup while keeping this test dependency-free. const previewJs = readFileSync(new URL("../web/static/js/preview.js", import.meta.url), "utf8"); const pageHtml = readFileSync(new URL("../web/static/dev.html", import.meta.url), "utf8"); +const chatJs = readFileSync(new URL("../web/static/js/chat.js", import.meta.url), "utf8"); test("HTML is a renderable category rather than plain text", () => { assert.match(previewJs, /html:\s+new Set\(\["html","htm"\]\)/); - assert.match(previewJs, /frame\.setAttribute\("sandbox", ""\)/); - assert.match(previewJs, /frame\.srcdoc = htmlPreviewDocument\(text\)/); + assert.match(previewJs, /configureHtmlPreviewFrame\(frame, text\)/); }); -test("HTML preview blocks scripts, navigation, and external resources", () => { +test("HTML preview allows HTTPS scripts while blocking host privileges and navigation", () => { const document = htmlPreviewDocument("xx"); assert.match(document, /")); - assert.doesNotMatch(previewJs, /allow-scripts/); + const attrs = {}; + const frame = { setAttribute(name, value) { attrs[name] = value; } }; + configureHtmlPreviewFrame(frame, "

x

"); + assert.equal(attrs.sandbox, "allow-scripts"); + assert.doesNotMatch(attrs.sandbox, /allow-same-origin|allow-forms|allow-top-navigation|allow-popups/); + assert.equal(frame.referrerPolicy, "no-referrer"); }); test("HTML fragments receive a restrictive head before their content", () => { @@ -38,3 +48,17 @@ test("main and mini previews expose preview/source mode controls", () => { assert.match(previewJs, new RegExp(`_showRenderableText\\("${prefix}", cat, text\\)`)); } }); + +test("assistant HTML artifacts render inline with lazy loading and an expand action", () => { + const mediaJs = readFileSync(new URL("../web/static/js/media.js", import.meta.url), "utf8"); + assert.match(mediaJs, /cat === "html"/); + assert.match(mediaJs, /class="art-html-open"/); + assert.match(mediaJs, /new IntersectionObserver/); + assert.match(mediaJs, /configureHtmlPreviewFrame\(frame, source/); + assert.match(pageHtml, /\.art-html-frame/); + assert.match(chatJs, /renderArtifactBarHtml\(extractArtifactRels\(p\.content, wd\), "html"\)/); + const clickHandler = chatJs.indexOf('$("chat-stream").addEventListener("click"'); + const expandHandler = chatJs.indexOf('e.target.closest(".art-html-open[data-rel]")'); + const sendMessage = chatJs.indexOf("async function sendMessage"); + assert.ok(clickHandler >= 0 && expandHandler > clickHandler && expandHandler < sendMessage); +}); diff --git a/web/static/dev.html b/web/static/dev.html index 820902c..87bccd1 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -1057,7 +1057,7 @@ font-size: 11px; cursor: pointer; transition: var(--t); } .paste-chip-del:hover { background: var(--c-red-bg); border-color: var(--c-red-bd); color: var(--c-red); } - /* 内联图片/视频:产物 chip 替代,fetch 完直接展示 */ + /* 内联图片/视频/HTML:产物 chip 替代,进入可视区后 fetch 并展示 */ .art-media { border: 1px solid var(--border); border-radius: var(--r-md); overflow: hidden; background: #fff; display: inline-block; line-height: 0; @@ -1251,6 +1251,28 @@ flex: 1; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .art-html { + flex: 1 1 100%; min-width: 0; max-width: 760px; + border: 1px solid var(--border); border-radius: var(--r-md); overflow: hidden; + background: #fff; font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; + } + .art-html-head { + height: 34px; padding: 4px 6px 4px 10px; display: flex; align-items: center; gap: 8px; + border-bottom: 1px solid var(--border); background: var(--panel-muted); line-height: 1.4; + } + .art-html-head > span { + flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--muted); font-size: 12px; + } + .art-html-open { flex: 0 0 auto; padding: 2px 8px; font-size: 11px; background: #fff; } + .art-html-viewport { + height: min(360px, 52vh); display: flex; align-items: center; justify-content: center; + background: #fff; line-height: 1.4; + } + .art-html-frame { display: block; width: 100%; height: 100%; border: 0; background: #fff; } + .art-html-viewport .art-media-loading, + .art-html-viewport .art-media-error { padding: 10px; color: var(--muted); font-size: 11px; } + .art-html-viewport .art-media-error { color: #b34a4a; } .preview-modes { display: inline-flex; align-items: center; gap: 2px; padding: 2px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--panel-muted); diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 340e561..2586b46 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -1581,12 +1581,12 @@ function renderMessages(msgs, { stickBottom = true } = {}) { if (typeof p.content === "string" && p.content) { html += `
${renderMd(p.content)}
`; if (role === "assistant") hasVisibleAssistantContent = true; - // assistant 正文里 echo 的 /... 路径**永远**挂 chip(绕开 seenRels —— 上面 - // tool 结果可能 inline 过同图,但 chip 是小按钮无视觉污染,助手回复里有可 - // 点的"产物锚点"比没有好);强制 allowInlineMedia=false 防止大图被重复 inline。 + // assistant 正文里 echo 的 /... 路径**永远**展示(绕开 seenRels)。图片/视频 + // 已可能在产物工具结果中内联,仍用 chip 防重复;HTML 通常由 write/shell 产出, + // 没有 producer 工具卡可承载,故在最终答复处直接升级为懒加载内嵌卡片。 if (role === "assistant") { const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); - html += renderArtifactBarHtml(extractArtifactRels(p.content, wd), false); + html += renderArtifactBarHtml(extractArtifactRels(p.content, wd), "html"); } } if (Array.isArray(p.tool_calls) && p.tool_calls.length) { @@ -2362,7 +2362,7 @@ document.addEventListener("keydown", (e) => { else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); stopVoiceInput(true); } }, true); -// 对话流里 artifact chip / 内联 img 点击委托 — 复用右栏文件预览 modal(modal 内自带"下载")。 +// 对话流里 artifact chip / 内联 img / HTML 放大按钮点击委托 — 复用文件预览 modal。 // 视频走原生