Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
18 KiB
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 <file>;服务端测试需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 路径同样生效,CSPsandbox仍阻止直接打开执行)。 - 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:
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:
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<string, string> = {
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
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 中:
- 删除顶部的
contentDisposition函数与CONTENT_TYPES常量(含两者的注释),改为:
import { fileResponseHeaders } from "@/lib/server/workspaceFileHeaders";
- GET 中解析 render 参数。现有:
const download = request.nextUrl.searchParams.get("download") === "1";
改为:
const download = request.nextUrl.searchParams.get("download") === "1";
const render = request.nextUrl.searchParams.get("render") === "1";
- 替换头部构造。现有(约 100-123 行):
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<Uint8Array>;
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 <img>/<iframe> preview rendering in the UI.
"Content-Security-Policy": "sandbox",
"X-Content-Type-Options": "nosniff",
// The path uniquely identifies a one-shot fetch; never cache stale agent output.
"Cache-Control": "no-store",
},
});
改为:
const ext = extname(target).slice(1).toLowerCase();
const fileName = basename(target);
const { contentType, disposition, csp } = fileResponseHeaders(
ext,
fileName,
{ download, render }
);
const nodeStream = createReadStream(target);
const webStream = Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>;
return new NextResponse(webStream, {
headers: {
"Content-Type": contentType,
"Content-Length": String(stat.size),
"Content-Disposition": disposition,
"Content-Security-Policy": csp,
// nosniff stops the browser from sniffing a text/* file into
// executable HTML when opened directly.
"X-Content-Type-Options": "nosniff",
// The path uniquely identifies a one-shot fetch; never cache stale agent output.
"Cache-Control": "no-store",
},
});
- Step 2: Typecheck
Run: npx tsc --noEmit
Expected: no errors
- Step 3: Run helper tests again (guard against import-cycle breakage)
Run: npx vitest run src/lib/server/workspaceFileHeaders.test.ts
Expected: PASS (10 tests)
- Step 4: Commit
git add src/app/api/workspace/file/route.ts
git commit -m "feat(webui): serve html as text/html with allow-scripts sandbox under render=1"
Task 3: 对话框 Preview/Source 切换
Files:
- Modify:
src/app/components/WorkspaceFileDialog.tsx
Interfaces:
-
Consumes: Task 2 的
workspaceFileUrl(threadId, path) + "&render=1"。 -
Produces: 无导出变化。
-
Step 1: Add htmlView state and reset
组件 state 区(const [deleting, setDeleting] = useState(false); 附近)追加:
// HTML files only: rendered preview vs syntax-highlighted source.
const [htmlView, setHtmlView] = useState<"preview" | "source">("preview");
// Bumped after a successful save so the preview iframe reloads new content.
const [savedTick, setSavedTick] = useState(0);
既有派生值区(const isMarkdown = ... 附近)追加:
const isHtml = ext === "html";
const showHtmlToggle = isHtml && kind === "text" && !tooBigForText && !editing;
在重置 docx fallback 的 useEffect(setDocxPreviewFallbackKey(null); 那个)里追加重置:
useEffect(() => {
setDocxPreviewFallbackKey(null);
setHtmlView("preview");
}, [path, threadId]);
- Step 2: Reload the preview after save
在 save() 成功分支(setContent(draft); setEditing(false); 之后)追加:
setSavedTick((tick) => tick + 1);
setHtmlView("preview");
- Step 3: Render the toggle buttons in the toolbar
在非编辑分支的工具栏({editable && (<Button ... Edit ... />)} 之前)插入:
{showHtmlToggle && (
<div
role="group"
aria-label="HTML view"
className="flex items-center rounded-md border border-border"
>
<Button
variant="ghost"
size="sm"
className={`h-8 rounded-r-none px-2 ${
htmlView === "preview" ? "bg-accent" : ""
}`}
onClick={() => setHtmlView("preview")}
aria-pressed={htmlView === "preview"}
>
<Eye size={16} className="mr-1" aria-hidden="true" />
Preview
</Button>
<Button
variant="ghost"
size="sm"
className={`h-8 rounded-l-none px-2 ${
htmlView === "source" ? "bg-accent" : ""
}`}
onClick={() => setHtmlView("source")}
aria-pressed={htmlView === "source"}
>
<Pencil size={16} className="mr-1" aria-hidden="true" />
Source
</Button>
</div>
)}
- Step 4: Render the preview iframe
iframe 的 h-full 需要父链一路有确定高度,因此不能放进最终 ScrollArea 内的 div.p-4(其高度为 auto,iframe 会塌成默认 150px)。正确做法是把 preview 作为内容区条件链的一个分支,插在最终 ScrollArea 分支之前。
内容区现有链尾:
) : officePreview?.kind === "spreadsheet" ? (
<SpreadsheetPreview
document={officePreview.document}
onError={setError}
/>
) : (
<ScrollArea className="h-full rounded-md bg-[var(--color-surface)]">
改为:
) : officePreview?.kind === "spreadsheet" ? (
<SpreadsheetPreview
document={officePreview.document}
onError={setError}
/>
) : isHtml && htmlView === "preview" && !tooBigForText ? (
<iframe
key={savedTick}
src={`${workspaceFileUrl(threadId, path)}&render=1`}
sandbox="allow-scripts"
title={name}
className="h-full w-full rounded-md border border-border bg-white"
/>
) : (
<ScrollArea className="h-full rounded-md bg-[var(--color-surface)]">
说明:该链位于 editing ? textarea : … 的非编辑侧,故此处无需再判 !editing;html 的 kind 必为 "text",isHtml && !tooBigForText 即等价于 showHtmlToggle 去掉 editing 条件。Source 视图无需改动——html 落入最终 ScrollArea 的 SyntaxHighlighter 分支(LANGUAGE_MAP.html = "html")正是现状。
- Step 5: Typecheck
Run: npx tsc --noEmit
Expected: no errors
-
Step 6: Manual verification(controller 处理,不在本任务)
-
Step 7: Commit
git add src/app/components/WorkspaceFileDialog.tsx
git commit -m "feat(webui): preview/source toggle for html files in workspace dialog"
Task 4: 全量回归
- Step 1: Run the full test suite
Run: npm test
Expected: 全部通过
- Step 2: Typecheck
Run: npx tsc --noEmit
Expected: no errors