# 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
/