Compare commits

...

2 Commits

9 changed files with 301 additions and 48 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。 > 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.60.25 — 2026-08-03
- HTML 产物现在可以在对话中直接显示进入可视区域后自动加载交互内容也可放大到弹窗查看HTML 与 Markdown 预览均可在渲染结果和源文件之间切换,常见 HTTPS 图表、地图及网页资源可正常加载。
## 0.60.24 — 2026-08-03 ## 0.60.24 — 2026-08-03
- 对话中的 Mermaid 流程图、柱状图和折线图现在可以直接显示;图表语法有误时会保留源码并给出提示。另修复嵌套代码块格式异常时后续正文整段变成代码的问题。 - 对话中的 Mermaid 流程图、柱状图和折线图现在可以直接显示;图表语法有误时会保留源码并给出提示。另修复嵌套代码块格式异常时后续正文整段变成代码的问题。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `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 ### 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.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.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 工具无需重建沙箱镜像。 - **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 工具无需重建沙箱镜像。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.60.24" __version__ = "0.60.25"

View File

@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
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, /configureHtmlPreviewFrame\(frame, text\)/);
});
test("HTML preview allows HTTPS scripts while blocking host privileges and navigation", () => {
const document = htmlPreviewDocument("<!doctype html><html><head><title>x</title></head><body>x</body></html>");
assert.match(document, /<head><meta http-equiv="Content-Security-Policy"/);
assert.match(HTML_PREVIEW_CSP, /script-src[^;]+https:/);
assert.match(HTML_PREVIEW_CSP, /connect-src https:/);
assert.match(document, /form-action 'none'/);
assert.match(document, /base-uri 'none'/);
assert.ok(document.indexOf("Content-Security-Policy") < document.indexOf("<title>"));
const attrs = {};
const frame = { setAttribute(name, value) { attrs[name] = value; } };
configureHtmlPreviewFrame(frame, "<h1>x</h1>");
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", () => {
const document = htmlPreviewDocument("<h1>报告</h1>");
assert.match(document, /^<head><meta http-equiv="Content-Security-Policy"/);
assert.match(document, /<\/head><h1>报告<\/h1>$/);
});
test("main and mini previews expose preview/source mode controls", () => {
for (const prefix of ["fp", "mp"]) {
assert.match(pageHtml, new RegExp(`id="${prefix}-mode-preview"`));
assert.match(pageHtml, new RegExp(`id="${prefix}-mode-source"`));
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);
});

View File

@ -1057,7 +1057,7 @@
font-size: 11px; cursor: pointer; transition: var(--t); 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); } .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 { .art-media {
border: 1px solid var(--border); border-radius: var(--r-md); overflow: hidden; border: 1px solid var(--border); border-radius: var(--r-md); overflow: hidden;
background: #fff; display: inline-block; line-height: 0; background: #fff; display: inline-block; line-height: 0;
@ -1251,7 +1251,40 @@
flex: 1; font-weight: 500; overflow: hidden; flex: 1; font-weight: 500; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; 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);
}
.preview-modes[hidden] { display: none; }
.preview-modes button { border: 0; background: transparent; }
.preview-modes button.active {
color: var(--accent); background: var(--accent-soft); box-shadow: 0 0 0 1px var(--accent);
}
#file-preview-modal .body { flex: 1; overflow: auto; padding: 12px; position: relative; overscroll-behavior: contain; } #file-preview-modal .body { flex: 1; overflow: auto; padding: 12px; position: relative; overscroll-behavior: contain; }
#file-preview-modal .body.html-preview,
#mini-preview-modal .body.html-preview { padding: 0; overflow: hidden; background: #fff; }
/* 预览缩放比例徽标(挂在 .card 上,放大滚动时不跟随内容滚走) */ /* 预览缩放比例徽标(挂在 .card 上,放大滚动时不跟随内容滚走) */
.zoom-badge { .zoom-badge {
position: absolute; right: 14px; bottom: 12px; z-index: 3; position: absolute; right: 14px; bottom: 12px; z-index: 3;
@ -1276,6 +1309,7 @@
max-width: 100%; max-height: 100%; display: block; margin: 0 auto; outline: none; max-width: 100%; max-height: 100%; display: block; margin: 0 auto; outline: none;
} }
#file-preview-modal .body iframe.preview-frame { width: 100%; height: 100%; border: 0; } #file-preview-modal .body iframe.preview-frame { width: 100%; height: 100%; border: 0; }
iframe.preview-html-frame { background: #fff; }
#file-preview-modal .body pre.preview-text { #file-preview-modal .body pre.preview-text {
margin: 0; padding: 8px; background: var(--code-bg); margin: 0; padding: 8px; background: var(--code-bg);
border-radius: var(--r-md); white-space: pre-wrap; word-break: break-word; border-radius: var(--r-md); white-space: pre-wrap; word-break: break-word;
@ -1971,6 +2005,10 @@
<div class="hdr"> <div class="hdr">
<span class="name" id="fp-name"></span> <span class="name" id="fp-name"></span>
<span class="small muted" id="fp-meta"></span> <span class="small muted" id="fp-meta"></span>
<span class="preview-modes" id="fp-modes" hidden aria-label="查看模式">
<button class="small" id="fp-mode-preview" type="button">预览</button>
<button class="small" id="fp-mode-source" type="button">源文件</button>
</span>
<button class="small" id="fp-download" title="下载原文件">下载</button> <button class="small" id="fp-download" title="下载原文件">下载</button>
<button class="small" id="fp-close" title="关闭 (Esc)">×</button> <button class="small" id="fp-close" title="关闭 (Esc)">×</button>
</div> </div>
@ -1984,6 +2022,10 @@
<div class="hdr"> <div class="hdr">
<span class="name" id="mp-name"></span> <span class="name" id="mp-name"></span>
<span class="small muted" id="mp-meta"></span> <span class="small muted" id="mp-meta"></span>
<span class="preview-modes" id="mp-modes" hidden aria-label="查看模式">
<button class="small" id="mp-mode-preview" type="button">预览</button>
<button class="small" id="mp-mode-source" type="button">源文件</button>
</span>
<button class="small" id="mp-download" title="下载原文件">下载</button> <button class="small" id="mp-download" title="下载原文件">下载</button>
<button class="small" id="mp-close" title="关闭 (Esc)">×</button> <button class="small" id="mp-close" title="关闭 (Esc)">×</button>
</div> </div>

View File

@ -1581,12 +1581,12 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
if (typeof p.content === "string" && p.content) { if (typeof p.content === "string" && p.content) {
html += `<div class="body">${renderMd(p.content)}</div>`; html += `<div class="body">${renderMd(p.content)}</div>`;
if (role === "assistant") hasVisibleAssistantContent = true; if (role === "assistant") hasVisibleAssistantContent = true;
// assistant 正文里 echo 的 <wd>/... 路径**永远**挂 chip(绕开 seenRels —— 上面 // assistant 正文里 echo 的 <wd>/... 路径**永远**展示(绕开 seenRels)。图片/视频
// tool 结果可能 inline 过同图,但 chip 是小按钮无视觉污染,助手回复里有可 // 已可能在产物工具结果中内联,仍用 chip 防重复HTML 通常由 write/shell 产出,
// 点的"产物锚点"比没有好);强制 allowInlineMedia=false 防止大图被重复 inline // 没有 producer 工具卡可承载,故在最终答复处直接升级为懒加载内嵌卡片
if (role === "assistant") { if (role === "assistant") {
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); 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) { 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); } else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); stopVoiceInput(true); }
}, true); }, true);
// 对话流里 artifact chip / 内联 img 点击委托 — 复用右栏文件预览 modal(modal 内自带"下载") // 对话流里 artifact chip / 内联 img / HTML 放大按钮点击委托 — 复用文件预览 modal。
// 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal — // 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal —
// 弹个 modal 反而打断播放,不如交给浏览器。 // 弹个 modal 反而打断播放,不如交给浏览器。
$("chat-stream").addEventListener("click", (e) => { $("chat-stream").addEventListener("click", (e) => {
@ -2394,6 +2394,12 @@ $("chat-stream").addEventListener("click", (e) => {
if (rel) openFilePreview(rel); if (rel) openFilePreview(rel);
return; return;
} }
const htmlOpen = e.target.closest && e.target.closest(".art-html-open[data-rel]");
if (htmlOpen) {
const rel = htmlOpen.dataset.rel;
if (rel) openFilePreview(rel);
return;
}
const inlineImg = e.target.closest && e.target.closest(".art-media-image[data-rel]"); const inlineImg = e.target.closest && e.target.closest(".art-media-image[data-rel]");
if (inlineImg) { if (inlineImg) {
const rel = inlineImg.dataset.rel; const rel = inlineImg.dataset.rel;

View File

@ -1,11 +1,12 @@
// 对话内的工具活动标签 + artifact(产物)抽取与渲染: // 对话内的工具活动标签 + artifact(产物)抽取与渲染:
// toolActivityLabel(工具调用→中文活动名)、extractArtifactRels(从结果文本/working_dir 提产物相对路径)、 // toolActivityLabel(工具调用→中文活动名)、extractArtifactRels(从结果文本/working_dir 提产物相对路径)、
// extractMediaBanner(seedream/seedance 媒体横幅)、renderArtifactBarHtml(产物 chip 条,图/视频内联占位)、 // extractMediaBanner(seedream/seedance 媒体横幅)、renderArtifactBarHtml(产物 chip 条,图/视频/HTML 内联占位)、
// upgradeMediaArtifacts(占位异步 fetch blob 填 <img>/<video>,带缓存)、downloadFile(blob 下载)。 // upgradeMediaArtifacts(可视区内异步填 <img>/<video>/<iframe>,带缓存)、downloadFile(blob 下载)。
// 供 main 的 renderMessages 及 preview/files(downloadFile)使用。 // 供 main 的 renderMessages 及 preview/files(downloadFile)使用。
import { state } from "./state.js"; import { state } from "./state.js";
import { escapeHtml } from "./format.js"; import { escapeHtml } from "./format.js";
import { _categorize } from "./preview.js"; import { _categorize } from "./preview.js";
import { configureHtmlPreviewFrame } from "./preview_content.js";
import { logout } from "./auth.js"; import { logout } from "./auth.js";
import { message } from "./dialog.js"; import { message } from "./dialog.js";
@ -164,16 +165,21 @@ export function extractArtifactRels(text, workingDir) {
return out; return out;
} }
// allowInlineMedia 控制图片/视频是否升级为内联 <img>/<video>:产物工具 // inlineMode 控制升级范围:true=图片/视频/HTML"html"=仅 HTMLfalse=全走 chip。
// (seedream/seedance)+ assistant 正文传 true,通用工具(grep/read/shell/glob) // 产物工具传 trueassistant 正文传 "html",避免重复内联图片/视频但让通用工具生成的
// 结果里 echo 的路径传 false → 图片/视频也走 chip 按钮(点开仍弹预览 modal), // HTML 有展示位;普通工具结果传 false引用到的文件仍走 chip避免把旧产物铺满消息。
// 这样既不会把无关老图占整屏,又保留"路径可点"的可发现性。 export function renderArtifactBarHtml(rels, inlineMode = true) {
export function renderArtifactBarHtml(rels, allowInlineMedia = true) {
if (!rels || !rels.length) return ""; if (!rels || !rels.length) return "";
const items = rels.map((rel) => { const items = rels.map((rel) => {
const name = rel.split("/").pop() || rel; const name = rel.split("/").pop() || rel;
const cat = _categorize(rel); const cat = _categorize(rel);
if (allowInlineMedia && (cat === "image" || cat === "video")) { if ((inlineMode === true || inlineMode === "html") && cat === "html") {
return `<section class="art-html" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)}">
<div class="art-html-head"><span>${escapeHtml(name)}</span><button type="button" class="art-html-open" data-rel="${escapeHtml(rel)}"></button></div>
<div class="art-html-viewport"><span class="art-media-loading">进入可视区域后加载</span></div>
</section>`;
}
if (inlineMode === true && (cat === "image" || cat === "video")) {
// 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 <img>/<video>。 // 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 <img>/<video>。
// 不在这里发请求避免 string-build 阶段失控的并发;upgrade 走 DOM walk 一次。 // 不在这里发请求避免 string-build 阶段失控的并发;upgrade 走 DOM walk 一次。
return `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}" title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`; return `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}" title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`;
@ -187,6 +193,8 @@ export function renderArtifactBarHtml(rels, allowInlineMedia = true) {
// 拿 blob 再转 URL。同 rel 在同会话内复用,免重复拉。task 切换 / logout 时 // 拿 blob 再转 URL。同 rel 在同会话内复用,免重复拉。task 切换 / logout 时
// _flushMediaArtifactCache 清掉旧 URL 防泄漏。 // _flushMediaArtifactCache 清掉旧 URL 防泄漏。
const _mediaArtifactCache = new Map(); const _mediaArtifactCache = new Map();
const _htmlArtifactCache = new Map();
const INLINE_HTML_MAX = 2 * 1024 * 1024;
function _fetchMediaBlobUrl(rel) { function _fetchMediaBlobUrl(rel) {
if (_mediaArtifactCache.has(rel)) return _mediaArtifactCache.get(rel); if (_mediaArtifactCache.has(rel)) return _mediaArtifactCache.get(rel);
@ -201,11 +209,64 @@ function _fetchMediaBlobUrl(rel) {
return p; return p;
} }
function _fetchHtmlArtifact(rel) {
if (_htmlArtifactCache.has(rel)) return _htmlArtifactCache.get(rel);
const p = fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => {
if (!r.ok) throw new Error("HTTP " + r.status);
const blob = await r.blob();
if (blob.size > INLINE_HTML_MAX) throw new Error("文件过大,请点击放大预览");
return blob.text();
});
_htmlArtifactCache.set(rel, p);
return p;
}
let _htmlArtifactObserver = null;
function _loadInlineHtml(node) {
if (!node || node.dataset.loaded) return;
node.dataset.loaded = "1";
const rel = node.dataset.rel;
const viewport = node.querySelector(".art-html-viewport");
_fetchHtmlArtifact(rel).then((source) => {
if (!node.isConnected || !viewport) return;
viewport.innerHTML = "";
const frame = document.createElement("iframe");
frame.className = "art-html-frame";
configureHtmlPreviewFrame(frame, source, (rel.split("/").pop() || rel) + " 内嵌预览");
viewport.appendChild(frame);
}).catch((e) => {
if (!viewport) return;
viewport.innerHTML = `<span class="art-media-error">${escapeHtml(e.message || String(e))}</span>`;
});
}
function _observeInlineHtml(node) {
if (typeof IntersectionObserver !== "function") {
_loadInlineHtml(node);
return;
}
if (!_htmlArtifactObserver) {
_htmlArtifactObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
_htmlArtifactObserver.unobserve(entry.target);
_loadInlineHtml(entry.target);
}
}, { rootMargin: "240px 0px" });
}
_htmlArtifactObserver.observe(node);
}
export function _flushMediaArtifactCache() { export function _flushMediaArtifactCache() {
for (const p of _mediaArtifactCache.values()) { for (const p of _mediaArtifactCache.values()) {
p.then((u) => URL.revokeObjectURL(u)).catch(() => {}); p.then((u) => URL.revokeObjectURL(u)).catch(() => {});
} }
_mediaArtifactCache.clear(); _mediaArtifactCache.clear();
_htmlArtifactCache.clear();
if (_htmlArtifactObserver) _htmlArtifactObserver.disconnect();
_htmlArtifactObserver = null;
} }
// DOM walk:把所有 .art-media[data-rel] 占位换成 <img> / <video>。 // DOM walk:把所有 .art-media[data-rel] 占位换成 <img> / <video>。
@ -235,6 +296,11 @@ export function upgradeMediaArtifacts(root) {
node.innerHTML = `<span class="art-media-error">${escapeHtml(rel.split("/").pop() || rel)} 加载失败:${escapeHtml(e.message || String(e))}</span>`; node.innerHTML = `<span class="art-media-error">${escapeHtml(rel.split("/").pop() || rel)} 加载失败:${escapeHtml(e.message || String(e))}</span>`;
}); });
}); });
const htmlNodes = (root || document).querySelectorAll(".art-html[data-rel]:not([data-upgraded])");
htmlNodes.forEach((node) => {
node.dataset.upgraded = "1";
_observeInlineHtml(node);
});
} }
export function downloadFile(rel) { export function downloadFile(rel) {

View File

@ -1,4 +1,4 @@
// 文件预览:主弹框(图/视频/PDF/文本/markdown/docx/xlsx,大文件降级下载)+ // 文件预览:主弹框(图/视频/PDF/文本/markdown/html/docx/xlsx,大文件降级下载)+
// 同时再开一个的小窗预览(mini)。docx/xlsx 走 loadScript 懒加载 vendor。 // 同时再开一个的小窗预览(mini)。docx/xlsx 走 loadScript 懒加载 vendor。
// 导出 open*/close* 供 files / 媒体 chip / 粘贴文件 / main 的 Esc 关栈调用; // 导出 open*/close* 供 files / 媒体 chip / 粘贴文件 / main 的 Esc 关栈调用;
// _categorize 也供 media 段判图/视频。反向依赖 downloadFile(media)、logout(auth)。 // _categorize 也供 media 段判图/视频。反向依赖 downloadFile(media)、logout(auth)。
@ -6,6 +6,7 @@ import { state } from "./state.js";
import { $ } from "./dom.js"; import { $ } from "./dom.js";
import { humanSize, escapeHtml } from "./format.js"; import { humanSize, escapeHtml } from "./format.js";
import { renderMd, highlightIn } from "./markdown.js"; import { renderMd, highlightIn } from "./markdown.js";
import { configureHtmlPreviewFrame } from "./preview_content.js";
import { logout } from "./auth.js"; import { logout } from "./auth.js";
import { downloadFile } from "./media.js"; import { downloadFile } from "./media.js";
@ -44,10 +45,11 @@ const _EXT_GROUPS = {
video: new Set(["mp4","webm","mov","mkv","m4v"]), video: new Set(["mp4","webm","mov","mkv","m4v"]),
pdf: new Set(["pdf"]), pdf: new Set(["pdf"]),
md: new Set(["md","markdown"]), md: new Set(["md","markdown"]),
html: new Set(["html","htm"]),
text: new Set([ text: new Set([
"txt","log","json","jsonl","yaml","yml","toml","ini","csv","tsv", "txt","log","json","jsonl","yaml","yml","toml","ini","csv","tsv",
"py","js","mjs","ts","jsx","tsx","go","rs","java","c","cc","cpp","h","hpp", "py","js","mjs","ts","jsx","tsx","go","rs","java","c","cc","cpp","h","hpp",
"html","htm","xml","css","scss","sh","bash","zsh","sql","conf","env", "xml","css","scss","sh","bash","zsh","sql","conf","env",
]), ]),
docx: new Set(["docx"]), docx: new Set(["docx"]),
xlsx: new Set(["xlsx","xls"]), xlsx: new Set(["xlsx","xls"]),
@ -62,6 +64,63 @@ export function _categorize(rel) {
let _fpCurrentRel = null; let _fpCurrentRel = null;
// Markdown / HTML 共用“预览 / 源文件”切换。HTML 在 opaque-origin sandbox iframe
// 中运行:可执行脚本、加载 HTTPS 资源,但不能读取 zcbot 页面或发起表单/顶层跳转。
const _textPreviewState = { fp: null, mp: null };
function _resetTextModes(prefix) {
_textPreviewState[prefix] = null;
const modes = $(`${prefix}-modes`);
if (modes) modes.hidden = true;
}
function _showSource(body, text) {
body.className = "body";
body.innerHTML = "";
const pre = document.createElement("pre");
pre.className = "preview-text";
pre.textContent = text;
body.appendChild(pre);
}
function _showHtml(body, text) {
body.className = "body html-preview";
body.innerHTML = "";
const frame = document.createElement("iframe");
frame.className = "preview-frame preview-html-frame";
configureHtmlPreviewFrame(frame, text);
body.appendChild(frame);
}
function _renderTextMode(prefix, mode) {
const state = _textPreviewState[prefix];
if (!state) return;
state.mode = mode;
const body = $(`${prefix}-body`);
const previewBtn = $(`${prefix}-mode-preview`);
const sourceBtn = $(`${prefix}-mode-source`);
const showingPreview = mode === "preview";
previewBtn.classList.toggle("active", showingPreview);
sourceBtn.classList.toggle("active", !showingPreview);
previewBtn.setAttribute("aria-pressed", String(showingPreview));
sourceBtn.setAttribute("aria-pressed", String(!showingPreview));
if (!showingPreview) {
_showSource(body, state.text);
} else if (state.cat === "md") {
body.className = "body";
body.innerHTML = `<div class="md-render">${renderMd(state.text)}</div>`;
highlightIn(body);
} else {
_showHtml(body, state.text);
}
}
function _showRenderableText(prefix, cat, text) {
_textPreviewState[prefix] = { cat, text, mode: "preview" };
$(`${prefix}-modes`).hidden = false;
_renderTextMode(prefix, "preview");
}
// ───── 滚动不穿透 + 图片 Ctrl+滚轮缩放 ───── // ───── 滚动不穿透 + 图片 Ctrl+滚轮缩放 ─────
// body 元素在多次预览间复用,故 wheel 监听只在 init 时挂一次(_bindBodyWheel), // body 元素在多次预览间复用,故 wheel 监听只在 init 时挂一次(_bindBodyWheel),
// 缩放目标用 _zoomState 记录,避免每次预览重复 addEventListener 泄漏。 // 缩放目标用 _zoomState 记录,避免每次预览重复 addEventListener 泄漏。
@ -181,6 +240,7 @@ export async function openFilePreview(rel) {
$("fp-name").textContent = name; $("fp-name").textContent = name;
$("fp-meta").textContent = ""; $("fp-meta").textContent = "";
const body = $("fp-body"); const body = $("fp-body");
_resetTextModes("fp");
_clearZoom(body); _clearZoom(body);
body.className = "body center"; body.className = "body center";
body.innerHTML = `<div class="ph">加载中…</div>`; body.innerHTML = `<div class="ph">加载中…</div>`;
@ -203,13 +263,13 @@ export async function openFilePreview(rel) {
const blob = await r.blob(); const blob = await r.blob();
$("fp-meta").textContent = humanSize(blob.size); $("fp-meta").textContent = humanSize(blob.size);
if (cat === "text" || cat === "md") { if (cat === "text" || cat === "md" || cat === "html") {
if (blob.size > PREVIEW_TEXT_MAX) { if (blob.size > PREVIEW_TEXT_MAX) {
_showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`); _showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
return; return;
} }
const text = await blob.text(); const text = await blob.text();
if (cat === "md") _showMarkdown(text); if (cat === "md" || cat === "html") _showRenderableText("fp", cat, text);
else _showText(text); else _showText(text);
return; return;
} }
@ -293,22 +353,7 @@ async function _showPptAsPdf(rel, body, metaEl, fallbackFn, trackFn = _trackBlob
body.innerHTML = `<iframe class="preview-frame" src="${trackFn(blob, "application/pdf")}"></iframe>`; body.innerHTML = `<iframe class="preview-frame" src="${trackFn(blob, "application/pdf")}"></iframe>`;
} }
function _showText(text) { function _showText(text) { _showSource($("fp-body"), text); }
const body = $("fp-body");
body.className = "body";
body.innerHTML = "";
const pre = document.createElement("pre");
pre.className = "preview-text";
pre.textContent = text;
body.appendChild(pre);
}
function _showMarkdown(text) {
const body = $("fp-body");
body.className = "body";
body.innerHTML = `<div class="md-render">${renderMd(text)}</div>`;
highlightIn(body);
}
async function _showDocx(blob) { async function _showDocx(blob) {
const body = $("fp-body"); const body = $("fp-body");
@ -404,6 +449,7 @@ export function closeFilePreview() {
document.body.classList.remove("fp-open"); document.body.classList.remove("fp-open");
$("file-preview-modal").style.removeProperty("--preview-bottom-inset"); $("file-preview-modal").style.removeProperty("--preview-bottom-inset");
_clearZoom($("fp-body")); _clearZoom($("fp-body"));
_resetTextModes("fp");
$("fp-body").innerHTML = ""; $("fp-body").innerHTML = "";
_flushBlobUrls(); _flushBlobUrls();
_fpCurrentRel = null; _fpCurrentRel = null;
@ -431,6 +477,7 @@ async function openMiniFilePreview(rel) {
$("mp-name").textContent = name; $("mp-name").textContent = name;
$("mp-meta").textContent = ""; $("mp-meta").textContent = "";
const body = $("mp-body"); const body = $("mp-body");
_resetTextModes("mp");
_clearZoom(body); _clearZoom(body);
body.className = "body center"; body.className = "body center";
body.innerHTML = `<div class="ph">加载中…</div>`; body.innerHTML = `<div class="ph">加载中…</div>`;
@ -449,23 +496,14 @@ async function openMiniFilePreview(rel) {
if (!r.ok) throw new Error("HTTP " + r.status); if (!r.ok) throw new Error("HTTP " + r.status);
const blob = await r.blob(); const blob = await r.blob();
$("mp-meta").textContent = humanSize(blob.size); $("mp-meta").textContent = humanSize(blob.size);
if (cat === "text" || cat === "md") { if (cat === "text" || cat === "md" || cat === "html") {
if (blob.size > PREVIEW_TEXT_MAX) { if (blob.size > PREVIEW_TEXT_MAX) {
_showMiniFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`); _showMiniFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
return; return;
} }
const text = await blob.text(); const text = await blob.text();
body.className = "body"; if (cat === "md" || cat === "html") _showRenderableText("mp", cat, text);
if (cat === "md") { else _showSource(body, text);
body.innerHTML = `<div class="md-render">${renderMd(text)}</div>`;
highlightIn(body);
} else {
body.innerHTML = "";
const pre = document.createElement("pre");
pre.className = "preview-text";
pre.textContent = text;
body.appendChild(pre);
}
return; return;
} }
if (blob.size > PREVIEW_BIN_MAX) { if (blob.size > PREVIEW_BIN_MAX) {
@ -518,6 +556,7 @@ function _showMiniFallback(msg) {
export function closeMiniPreview() { export function closeMiniPreview() {
$("mini-preview-modal").classList.remove("show"); $("mini-preview-modal").classList.remove("show");
_clearZoom($("mp-body")); _clearZoom($("mp-body"));
_resetTextModes("mp");
$("mp-body").innerHTML = ""; $("mp-body").innerHTML = "";
_flushMiniBlobUrls(); _flushMiniBlobUrls();
_mpCurrentRel = null; _mpCurrentRel = null;
@ -534,11 +573,15 @@ _bindBodyWheel($("mp-body"));
$("fp-close").onclick = closeFilePreview; $("fp-close").onclick = closeFilePreview;
$("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); }; $("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); };
$("fp-mode-preview").onclick = () => _renderTextMode("fp", "preview");
$("fp-mode-source").onclick = () => _renderTextMode("fp", "source");
$("file-preview-modal").addEventListener("click", (e) => { $("file-preview-modal").addEventListener("click", (e) => {
if (e.target.id === "file-preview-modal") closeFilePreview(); if (e.target.id === "file-preview-modal") closeFilePreview();
}); });
$("mp-close").onclick = closeMiniPreview; $("mp-close").onclick = closeMiniPreview;
$("mp-download").onclick = () => { if (_mpCurrentRel) downloadFile(_mpCurrentRel); }; $("mp-download").onclick = () => { if (_mpCurrentRel) downloadFile(_mpCurrentRel); };
$("mp-mode-preview").onclick = () => _renderTextMode("mp", "preview");
$("mp-mode-source").onclick = () => _renderTextMode("mp", "source");
$("mini-preview-modal").addEventListener("click", (e) => { $("mini-preview-modal").addEventListener("click", (e) => {
if (e.target.id === "mini-preview-modal") closeMiniPreview(); if (e.target.id === "mini-preview-modal") closeMiniPreview();
}); });

View File

@ -0,0 +1,27 @@
// 可独立测试的 HTML 预览安全策略。脚本和 HTTPS 外部资源可运行,满足图表、
// 地图、WebGL 等通用产物iframe 保持 opaque origin不能触达 zcbot 宿主权限。
export const HTML_PREVIEW_CSP = "default-src 'none'; " +
"script-src 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' https:; " +
"style-src 'unsafe-inline' https:; img-src data: blob: https:; " +
"media-src data: blob: https:; font-src data: https:; connect-src https:; " +
"worker-src blob:; child-src blob: https:; frame-src https:; manifest-src https:; " +
"object-src 'none'; form-action 'none'; base-uri 'none'";
export const HTML_PREVIEW_SANDBOX = "allow-scripts";
export function htmlPreviewDocument(text) {
const meta = `<meta http-equiv="Content-Security-Policy" content="${HTML_PREVIEW_CSP}">`;
const source = String(text || "");
if (/<head(?:\s[^>]*)?>/i.test(source)) {
return source.replace(/<head(?:\s[^>]*)?>/i, (head) => head + meta);
}
return `<head>${meta}</head>${source}`;
}
export function configureHtmlPreviewFrame(frame, text, title = "HTML 文件预览") {
frame.title = title;
frame.setAttribute("sandbox", HTML_PREVIEW_SANDBOX);
frame.setAttribute("csp", HTML_PREVIEW_CSP);
frame.referrerPolicy = "no-referrer";
frame.srcdoc = htmlPreviewDocument(text);
return frame;
}