# HTML Preview/Source Toggle Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Workspace 文件对话框中 HTML 文件支持 Preview(渲染)/Source(源码)切换,默认 Preview,允许脚本执行,相对资源可解析。 **Architecture:** 服务端为 `GET /api/workspace/file` 增加 `?render=1`(仅 html):text/html + CSP `sandbox allow-scripts`,响应头逻辑抽成纯函数便于测试;对话框对 html 文件增加 Preview/Source 分段切换,Preview 用 sandboxed iframe 加载 render URL。 **Tech Stack:** Next.js App Router, React, Vitest。 ## Global Constraints - 测试命令:`npx vitest run `;服务端测试需 `vi.mock("server-only", () => ({}));`。 - render 模式:`Content-Type: text/html; charset=utf-8`、`Content-Security-Policy: sandbox allow-scripts`、`Content-Disposition: inline`、保留 `Cache-Control: no-store` 与 `X-Content-Type-Options: nosniff`。 - `render=1` 仅对 `.html` 生效;`?download=1` 优先于 `render=1`;非 render 行为完全不变。 - MIME 修正:`css → text/css; charset=utf-8`,`js → text/javascript; charset=utf-8`(非 render 路径同样生效,CSP `sandbox` 仍阻止直接打开执行)。 - iframe 属性:`sandbox="allow-scripts"`(不含 allow-same-origin / allow-forms / allow-modals / allow-popups)。 - 默认视图 Preview;切换不持久化;保存成功后回到 Preview 并强制 iframe 重载。 - 设计文档:`docs/superpowers/specs/2026-08-08-html-preview-toggle-design.md`。 --- ### Task 1: 响应头纯函数 `fileResponseHeaders` **Files:** - Create: `src/lib/server/workspaceFileHeaders.ts` - Test: `src/lib/server/workspaceFileHeaders.test.ts` **Interfaces:** - Produces: - `fileResponseHeaders(ext: string, fileName: string, opts: { download?: boolean; render?: boolean }): { contentType: string; disposition: string; csp: string }` - Consumes(Task 2): 路由用其返回值设置 `Content-Type`、`Content-Disposition`、`Content-Security-Policy` 头。 说明:现有路由内嵌的 `CONTENT_TYPES` 映射、`contentDisposition()`、attachment 判定全部移入此模块,并加入 render 分支与 css/js MIME 修正。`Cache-Control`/`X-Content-Type-Options` 固定头留在路由(不经过此函数)。 - [ ] **Step 1: Write the failing test** `src/lib/server/workspaceFileHeaders.test.ts`: ```ts import { describe, expect, it } from "vitest"; import { fileResponseHeaders } from "./workspaceFileHeaders"; describe("fileResponseHeaders", () => { it("renders html as text/html with an allow-scripts sandbox in render mode", () => { const h = fileResponseHeaders("html", "report.html", { render: true }); expect(h.contentType).toBe("text/html; charset=utf-8"); expect(h.csp).toBe("sandbox allow-scripts"); expect(h.disposition).toMatch(/^inline;/); expect(h.disposition).toContain('filename="report.html"'); }); it("ignores render for non-html extensions", () => { const h = fileResponseHeaders("md", "notes.md", { render: true }); expect(h.contentType).toBe("text/markdown; charset=utf-8"); expect(h.csp).toBe("sandbox"); }); it("lets download take precedence over render for html", () => { const h = fileResponseHeaders("html", "report.html", { render: true, download: true, }); expect(h.contentType).toBe("text/plain; charset=utf-8"); expect(h.csp).toBe("sandbox"); expect(h.disposition).toMatch(/^attachment;/); }); it("keeps plain html (no render) as text/plain with a full sandbox", () => { const h = fileResponseHeaders("html", "report.html", {}); expect(h.contentType).toBe("text/plain; charset=utf-8"); expect(h.csp).toBe("sandbox"); expect(h.disposition).toMatch(/^inline;/); }); it("serves css and js with executable MIME types so render-mode pages can use them", () => { expect(fileResponseHeaders("css", "a.css", {}).contentType).toBe( "text/css; charset=utf-8" ); expect(fileResponseHeaders("js", "a.js", {}).contentType).toBe( "text/javascript; charset=utf-8" ); }); it("keeps images and pdf inline with their MIME types", () => { expect(fileResponseHeaders("png", "a.png", {}).contentType).toBe("image/png"); expect(fileResponseHeaders("pdf", "a.pdf", {}).contentType).toBe("application/pdf"); expect(fileResponseHeaders("png", "a.png", {}).disposition).toMatch(/^inline;/); }); it("forces unknown extensions to attachment octet-stream", () => { const h = fileResponseHeaders("bin", "a.bin", {}); expect(h.contentType).toBe("application/octet-stream"); expect(h.disposition).toMatch(/^attachment;/); }); it("forces download=1 to attachment for inline types", () => { const h = fileResponseHeaders("png", "a.png", { download: true }); expect(h.disposition).toMatch(/^attachment;/); }); it("keeps ts/tsx as plain text (never executed)", () => { expect(fileResponseHeaders("ts", "a.ts", {}).contentType).toBe( "text/plain; charset=utf-8" ); expect(fileResponseHeaders("tsx", "a.tsx", {}).contentType).toBe( "text/plain; charset=utf-8" ); }); it("encodes non-ASCII filenames per RFC 6266", () => { const h = fileResponseHeaders("html", "报告.html", { render: true }); expect(h.disposition).toContain('filename="__.html"'); expect(h.disposition).toContain("filename*=UTF-8''"); expect(h.disposition).toContain("%E6%8A%A5%E5%91%8A"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/lib/server/workspaceFileHeaders.test.ts` Expected: FAIL — `Cannot find module './workspaceFileHeaders'` - [ ] **Step 3: Write the implementation** `src/lib/server/workspaceFileHeaders.ts`: ```ts import "server-only"; /** RFC 6266 Content-Disposition value with both an ASCII fallback and a UTF-8 * `filename*` so non-ASCII names (e.g. Chinese) download with their real name * instead of percent-encoded gibberish. */ function contentDisposition(fileName: string, asAttachment: boolean): string { const ascii = fileName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); const encoded = encodeURIComponent(fileName).replace( /['()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase() ); return `${ asAttachment ? "attachment" : "inline" }; filename="${ascii}"; filename*=UTF-8''${encoded}`; } // Map common research-output extensions to a Content-Type. Anything unlisted is // served as a download (octet-stream) so the browser never tries to execute it. const CONTENT_TYPES: Record = { txt: "text/plain; charset=utf-8", md: "text/markdown; charset=utf-8", log: "text/plain; charset=utf-8", csv: "text/csv; charset=utf-8", tsv: "text/tab-separated-values; charset=utf-8", json: "application/json; charset=utf-8", py: "text/plain; charset=utf-8", ts: "text/plain; charset=utf-8", tsx: "text/plain; charset=utf-8", sh: "text/plain; charset=utf-8", yaml: "text/plain; charset=utf-8", yml: "text/plain; charset=utf-8", toml: "text/plain; charset=utf-8", tex: "text/plain; charset=utf-8", bib: "text/plain; charset=utf-8", html: "text/plain; charset=utf-8", // never text/html without render=1 xml: "text/plain; charset=utf-8", // css/js use executable MIME types so pages loaded with ?render=1 can apply // their same-directory assets (nosniff blocks text/plain). Directly opening // them stays safe: the CSP sandbox forbids script execution there. css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml", bmp: "image/bmp", pdf: "application/pdf", }; export interface FileResponseHeaders { contentType: string; disposition: string; csp: string; } export function fileResponseHeaders( ext: string, fileName: string, opts: { download?: boolean; render?: boolean } ): FileResponseHeaders { const download = opts.download === true; // render=1 is honored only for html and never overrides an explicit download. if (ext === "html" && opts.render === true && !download) { return { contentType: "text/html; charset=utf-8", disposition: contentDisposition(fileName, false), // allow-scripts for interactive reports (plotly/echarts); no // allow-same-origin, so scripts can't reach the app's cookies or DOM. csp: "sandbox allow-scripts", }; } const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream"; const asAttachment = download || contentType === "application/octet-stream"; return { contentType, disposition: contentDisposition(fileName, asAttachment), // Workspace files are agent/user-controlled. `sandbox` neutralizes scripts // in an inline SVG/HTML opened directly (XSS). csp: "sandbox", }; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run src/lib/server/workspaceFileHeaders.test.ts` Expected: PASS (10 tests) - [ ] **Step 5: Commit** ```bash git add src/lib/server/workspaceFileHeaders.ts src/lib/server/workspaceFileHeaders.test.ts git commit -m "feat(webui): fileResponseHeaders with render-mode for html preview" ``` --- ### Task 2: 路由接入 render 参数 **Files:** - Modify: `src/app/api/workspace/file/route.ts`(GET 的头部构造段,约 100-123 行;删除文件顶部 `contentDisposition` 与 `CONTENT_TYPES`) **Interfaces:** - Consumes: Task 1 `fileResponseHeaders(ext, fileName, { download, render })`。 - Produces: `GET /api/workspace/file?threadId=..&path=..&render=1`(Task 3 的 iframe src)。 - [ ] **Step 1: Replace inline header logic with the helper** 在 `src/app/api/workspace/file/route.ts` 中: 1. 删除顶部的 `contentDisposition` 函数与 `CONTENT_TYPES` 常量(含两者的注释),改为: ```ts import { fileResponseHeaders } from "@/lib/server/workspaceFileHeaders"; ``` 2. GET 中解析 render 参数。现有: ```ts const download = request.nextUrl.searchParams.get("download") === "1"; ``` 改为: ```ts const download = request.nextUrl.searchParams.get("download") === "1"; const render = request.nextUrl.searchParams.get("render") === "1"; ``` 3. 替换头部构造。现有(约 100-123 行): ```ts const ext = extname(target).slice(1).toLowerCase(); const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream"; // Octet-stream and explicit ?download=1 go out as attachments; previewable // types render inline. const asAttachment = download || contentType === "application/octet-stream"; const nodeStream = createReadStream(target); const webStream = Readable.toWeb(nodeStream) as ReadableStream; const fileName = basename(target); return new NextResponse(webStream, { headers: { "Content-Type": contentType, "Content-Length": String(stat.size), "Content-Disposition": contentDisposition(fileName, asAttachment), // Workspace files are agent/user-controlled. `sandbox` neutralizes // scripts in an inline SVG/HTML opened directly (XSS), and `nosniff` // stops the browser from sniffing a text/* file into executable HTML. // Neither affects /