Files
EvoScientist-WebUI/docs/superpowers/plans/2026-08-08-html-preview-toggle.md
2026-08-08 11:14:17 +08:00

18 KiB
Raw Permalink Blame History

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 路径同样生效,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:

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 中:

  1. 删除顶部的 contentDisposition 函数与 CONTENT_TYPES 常量(含两者的注释),改为:
import { fileResponseHeaders } from "@/lib/server/workspaceFileHeaders";
  1. 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";
  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