chore: import upstream snapshot with attribution
CI / Public checks (push) Failing after 0s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:27:29 +08:00
commit e2bed4b5a2
375 changed files with 55814 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
name: CI
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
jobs:
public-checks:
name: Public checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Enable Corepack
run: corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Guard project shape
run: pnpm exec tsx scripts/guard.ts
- name: Typecheck app
run: pnpm -F @html-anything/next typecheck
- name: Typecheck e2e
run: pnpm -F @html-anything/e2e typecheck
- name: App unit tests
run: pnpm -F @html-anything/next test
- name: Build
run: pnpm -F @html-anything/next build
- name: Install Playwright browser
run: pnpm -F @html-anything/e2e exec playwright install --with-deps chromium
- name: E2E smoke
run: pnpm -F @html-anything/e2e test
+53
View File
@@ -0,0 +1,53 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/e2e/node_modules
/next/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
/test-results/
/playwright-report/
/e2e/ui/reports/
/e2e/ui/test-results/
# next.js
/.next/
/next/.next/
/out/
/next/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/next/next-env.d.ts
# claude code
.claude-sessions/
+24
View File
@@ -0,0 +1,24 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `next/node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
## Workspace Shape
- Root is the public harness boundary only: workspace metadata, `.github/workflows/ci.yml`, `scripts/guard.ts`, and repository docs.
- `next/` owns the complete Next application: app routes, components, app libraries, public assets, Next config, app TypeScript config, and app unit tests.
- `e2e/` owns browser-level tests as the only source of truth: Playwright config, e2e TypeScript config, helper scripts, and flat `ui/*.test.ts` cases.
- Do not add Playwright tests under `next/`. Do not add app source back at root `src/` or root `app/`.
- Root `package.json` must not proxy app or e2e scripts. Use pnpm workspace filters from the repository root.
## Commands
- Install: `pnpm install --frozen-lockfile`
- Guard shape: `pnpm exec tsx scripts/guard.ts`
- App dev: `pnpm -F @html-anything/next dev`
- App typecheck: `pnpm -F @html-anything/next typecheck`
- App unit tests: `pnpm -F @html-anything/next test`
- App build: `pnpm -F @html-anything/next build`
- E2E typecheck: `pnpm -F @html-anything/e2e typecheck`
- E2E tests: `pnpm -F @html-anything/e2e test`
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+241
View File
@@ -0,0 +1,241 @@
# Contributing to HTML Anything
Thanks for thinking about contributing. HTML Anything is small on purpose — most of the value lives in **files** (skill folders, prompt fragments, agent adapters) rather than framework code. The highest-leverage contributions are usually one folder, one Markdown file, or a ten-line adapter.
This guide tells you exactly where to look for each type of contribution and what bar a PR has to clear before we merge.
<p align="center"><b>English</b> · <a href="CONTRIBUTING.zh-CN.md">简体中文</a></p>
---
## Three things you can ship in one afternoon
| If you want to… | You're really adding | Where it lives | Ship size |
|---|---|---|---|
| Make HTML Anything render a new kind of artifact (an invoice, a job posting, an iOS Settings screen…) | a **Skill** | [`src/lib/templates/skills/<your-skill>/`](src/lib/templates/skills/) | one folder, ~3 files |
| Hook up a new coding-agent CLI | an **Agent adapter** | [`src/lib/agents/argv.ts`](src/lib/agents/argv.ts) + [`src/lib/agents/detect.ts`](src/lib/agents/detect.ts) | ~10 lines in one array |
| Add a new export target (WeChat Channels, Douyin captions, Notion, …) | an **Export adapter** | [`src/components/drafts-menu.tsx`](src/components/drafts-menu.tsx) + helper under `src/lib/export/` | one component + one helper |
| Add a feature, fix a bug, refactor the streaming parser | code | `src/app/`, `src/lib/`, `src/components/` | normal PR |
| Improve docs, port a section into another language, fix typos | docs | `README.md`, `README.zh-CN.md`, this file | one PR |
If you're not sure which bucket your idea is in, [open an issue first](https://github.com/nexu-io/html-anything/issues/new) and we'll point you at the right surface.
---
## Local setup
```bash
git clone https://github.com/nexu-io/html-anything.git
cd html-anything
pnpm install
pnpm dev # next dev — http://localhost:3000
pnpm build # next build, when verifying a release-shaped bundle
```
Node `~20` and `pnpm` are required. macOS, Linux, and WSL2 are the primary paths. Native Windows should work but isn't a primary target — file an issue if it doesn't.
Before you push, make sure you have **at least one coding-agent CLI logged in** (`claude login`, `cursor login`, `gemini auth`, etc.) so you can actually run an end-to-end generation. PRs that touch the streaming or agent layer are expected to include a screenshot or log snippet showing it worked.
> **A note on this project.** It is a normal Next.js 16 App Router app — no daemon, no Electron shell, no extra processes. Everything happens in `next dev`: server routes spawn the local CLI, stream stdout back as SSE, and the browser appends into an iframe `srcdoc`. If you find yourself wanting to introduce a separate long-running process, please open a discussion first.
---
## Adding a new Skill
A skill is a folder under [`src/lib/templates/skills/`](src/lib/templates/skills/) with a `SKILL.md` at the root, following Claude Code's [`SKILL.md` convention][skill] plus a small extended frontmatter that the picker reads. **No registration step.** Drop the folder in, restart `pnpm dev`, the picker shows it.
### Skill folder layout
```text
src/lib/templates/skills/your-skill/
├── SKILL.md # required — prompt body + frontmatter
├── example.html # required — what the agent should produce, hand-authored
├── assets/ # optional — fonts, images, reusable CSS, layout fragments
└── references/ # optional — design-system snippets, references the agent should Read
```
### `SKILL.md` shape
```markdown
---
name: your-skill
description: One sentence shown in the picker preview.
mode: prototype # prototype | deck | frame | social | office | doc | mockup | vfx
scenario: marketing # design | marketing | engineering | product | finance | hr | sale | personal
surface: desktop # desktop | mobile | A4 | 1080x1920 | 1600x900 | 1920x1080 | …
preview:
type: iframe # iframe | image | deck
thumbnail: docs/screenshots/skills/your-skill.png # optional, used in the README showcase grid
design_system:
requires: optional # required | optional | none
featured: false # set true if this should appear in "Showcase examples"
example_prompt: |
Three-sentence example prompt that demonstrates the kind of input this skill is for.
---
# Your Skill
<one-paragraph identity / discipline statement — voice, intent, what the agent must commit to>
## Hard constraints
- 8 px baseline grid · all spacing / line-height / font-size as multiples of 8.
- CJK-first font stack: `"Noto Sans SC", "PingFang SC", "Source Han Sans"`. Latin: `"Inter", "Manrope"`.
- Color contrast ≥ 4.5. Real `:focus` state on every interactive element.
- Must use the user's real data. No `lorem ipsum`. No invented metrics. No purple gradients.
## Layout
<the structure / sections / hierarchy the agent should produce, with concrete tokens
— sizes, ratios, named slots — not vague aesthetic prose>
## What "good" looks like
- <one-line positive example>
- <another>
## What "bad" looks like
- <one-line negative example, mirror of the slop blacklist if relevant>
- <another>
```
### Bar for merging a new skill
1. **Real `example.html` ships in the folder.** Hand-author it once — the agent has a target to copy. PRs without one get bounced.
2. **The example renders in the browser** (`pnpm dev` → pick the skill → ⌘+Enter → screenshot). Attach the screenshot to the PR.
3. **Hard constraints exist and are specific.** Vague directives ("use modern typography") are not constraints. Real ones look like "Inter 96 / 64 / 40 / 24 / 16 px, 8 px grid, max two weights per slide".
4. **No `lorem ipsum`** anywhere in the example. If the example uses placeholder data, it must be plausibly-real placeholder data.
5. **Slug uses ASCII lowercase with dashes**`deck-swiss-international`, `social-x-post-card`. Mirror the 75 existing folders.
6. **If you vendored work from another repo**, the original `LICENSE` and authorship attribution have to ship inside your skill folder. Example: `src/lib/templates/skills/deck-guizang-editorial/LICENSE` preserves the original op7418 license verbatim.
### Picker grouping
The picker organizes skills along two axes. Pick values that already exist where possible — only introduce a new value if your skill genuinely doesn't fit:
- **`mode`** — `prototype` · `deck` · `frame` · `social` · `office` · `doc` · `mockup` · `vfx`.
- **`scenario`** — `design` · `marketing` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`.
---
## Adding a new coding-agent CLI
Hooking up a new agent (e.g. some new shop's `foo-coder` CLI) is one entry in [`src/lib/agents/argv.ts`](src/lib/agents/argv.ts):
```ts
{
id: 'foo',
name: 'Foo Coder',
bin: 'foo',
detect: { args: ['--version'] },
build: (prompt: string) => ({
args: ['exec', '-p', prompt],
stdin: null, // or 'prompt' if the CLI reads from stdin
}),
stream: 'plain', // 'plain' | 'json-event' | 'claude-stream-json'
}
```
That's it. `/api/agents` will detect it on `PATH`, the top-bar picker shows it, the chat path works through the same SSE pipeline. If the CLI emits **typed events** (like Claude Code's `--output-format stream-json`), add a parser in [`src/lib/agents/invoke.ts`](src/lib/agents/invoke.ts) and set `stream: 'claude-stream-json'`.
### Bar for merging an agent adapter
1. **A real session works end-to-end.** Run `pnpm dev`, pick your agent, generate any skill's `example_prompt`, and paste the SSE log into the PR description showing it streamed an artifact through.
2. **`PATH` detection works on macOS, Linux, and WSL.** The scanner already includes `~/.local/bin` · `~/.bun/bin` · `/opt/homebrew/bin` · `~/.npm-global/bin`; if your CLI lives somewhere else, add the dir to the scan list.
3. **The README's "Supported coding agents" table gets one row** in both `README.md` and `README.zh-CN.md`.
4. **Stream parser is reusable.** If the CLI emits the same JSON-line shape as another adapter, share the parser — don't fork.
---
## Adding a new export target
Export targets live in two places: a helper under `src/lib/export/` that produces the bytes (string for `.html`, Blob for `.png`, `ClipboardItem` for paste), and a menu entry in [`src/components/drafts-menu.tsx`](src/components/drafts-menu.tsx) that wires it into the UI.
### Bar for merging an export target
1. **Round-trip test in the target platform.** Paste / upload the output into the platform itself (WeChat editor, X composer, Zhihu editor) and screenshot the result in the PR.
2. **No platform credentials in code.** If the target needs an API key, the user supplies it in settings; we don't ship one.
3. **Output is self-contained.** `.html` exports inline their CSS via `juice`; PNG exports use `modern-screenshot` at 2×.
---
## Code style
We're not pedantic about formatting (Prettier on save is fine), but two rules are non-negotiable because they show up in the prompt stack and the user-facing API:
1. **Single quotes in TS/TSX.** Strings are single-quoted unless escaping makes them ugly. The codebase is already consistent — please match.
2. **Comments in English.** Even if the PR is translating something into 中文, code comments stay in English so we can keep one set of greppable references.
Beyond that:
- **Don't narrate.** No `// import the module`, no `// loop through items`. If the code reads obviously, the comment is noise. Save comments for non-obvious intent or constraints the code can't express.
- **TypeScript for `src/`.** No new top-level `.js` files unless there's a compelling reason.
- **No new top-level dependencies** without a paragraph in the PR description on what we get vs. what bytes we ship. The dep list in [`package.json`](package.json) is small on purpose.
- **Run `pnpm build`** before pushing structural changes. Type errors block merge.
---
## Commits & pull requests
- **One concern per PR.** Adding a skill + refactoring the SSE parser + bumping a dep is three PRs.
- **Title is imperative + scope.** `add deck-product-launch skill`, `fix SSE backpressure when CLI hangs`, `docs: clarify skill frontmatter`.
- **Body explains the why.** "What does this do" is usually obvious from the diff; "why does this need to exist" rarely is.
- **Reference an issue** if there is one. If there isn't and the PR is non-trivial, open one first so we can agree the change is wanted before you spend the time.
- **No squash-during-review.** Push fixups; we'll squash on merge.
- **No force-push to a shared branch** unless the reviewer asked.
We don't enforce a CLA. Apache-2.0 covers us; your contribution is licensed under the same.
---
## Reporting bugs
Open an issue with:
- What you ran (the exact `pnpm dev` invocation, or which UI button you clicked).
- Which agent CLI was selected (Claude Code? Cursor Agent? …).
- The skill that triggered it.
- The relevant **server log tail** — most "the artifact never rendered" reports get diagnosed in 30 seconds when we can see `spawn ENOENT` or the CLI's actual error.
- A screenshot if it's UI.
For prompt-stack bugs ("the agent emitted a purple gradient hero, the constraint in `SKILL.md` was supposed to forbid that"), include the **full assistant message** so we can see whether the violation was the model or the prompt.
---
## Asking questions
- Architecture question, design question, "is this a bug or a misuse" → [GitHub Discussions](https://github.com/nexu-io/html-anything/discussions) (preferred — searchable for the next person).
- "How do I write a skill that does X" → open a discussion. We'll answer it and turn the answer into an entry in this guide if the pattern is missing.
---
## What we don't accept
To keep the project focused, please don't open PRs that:
- **Vendor a model runtime.** HTML Anything's whole bet is "your existing CLI is enough". We don't ship `pi-ai`, OpenAI keys, model loaders, or hosted inference proxies.
- **Rewrite the frontend away from the current stack without prior discussion.** Next.js 16 App Router + React 19 + Tailwind v4 + TypeScript is the line. No Astro, Solid, Svelte, or other framework rewrites unless maintainers explicitly want that migration.
- **Replace the SSE streaming model with WebSocket / long-polling.** SSE + iframe `srcdoc` append is the simplest thing that ships a live render; we keep it.
- **Add telemetry / analytics / phone-home.** HTML Anything is local-first. The only outbound calls are to the agent CLI on your laptop, plus whatever the generated HTML itself references (Tailwind CDN, Google Fonts).
- **Bundle a binary** without a license file and authorship attribution next to it.
- **Add a skill whose `example.html` is empty / placeholder / clearly model-generated without review.** A skill is judged by what its example renders; an empty example is rejected immediately.
If you're not sure whether your idea fits, open a discussion before writing the code.
---
## Localization maintenance
The repo ships two languages at parity: English (`README.md`, `CONTRIBUTING.md`) and 简体中文 (`README.zh-CN.md`, `CONTRIBUTING.zh-CN.md`). When adding or renaming a skill, agent, or export target:
- Update **both** READMEs' tables.
- Update **both** CONTRIBUTING docs if your change introduces a new contribution surface or a new bar a PR has to clear.
- **Skill prompt bodies stay in their source language.** Don't translate `SKILL.md` — it's part of the prompt stack the agent reads, and keeping one source language avoids multiplying prompt QA across locales.
- Daemon error messages, file names, and agent-generated artifact text are known limitations unless a PR explicitly scopes them.
---
## License
By contributing, you agree your contribution is licensed under the [Apache-2.0 License](LICENSE) of this repository.
Vendored work retains its original license and authorship attribution — see each `src/lib/templates/skills/<skill>/` folder's own `LICENSE` / `README.md` for what it inherits from upstream. The most prominent example is [`src/lib/templates/skills/deck-guizang-editorial/`](src/lib/templates/skills/deck-guizang-editorial/), which retains the original license and authorship attribution to [op7418](https://github.com/op7418).
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
+240
View File
@@ -0,0 +1,240 @@
# 为 HTML Anything 贡献代码
谢谢你愿意贡献。HTML Anything 是一个故意保持小的项目 —— 大部分价值都在**文件**里(skill 文件夹、提示词片段、agent adapter),而不在框架代码里。回报率最高的贡献,通常是一个文件夹、一个 Markdown 文件,或者十行 adapter。
这份指南告诉你:每种贡献该放在哪个目录,PR 在合并前要过哪些线。
<p align="center"><a href="CONTRIBUTING.md">English</a> · <b>简体中文</b></p>
---
## 一个下午就能交付的三件事
| 你想做什么 | 你其实是在加 | 文件位置 | 体积 |
|---|---|---|---|
| 让 HTML Anything 渲染一种新作品(发票、招聘启事、iOS 设置页…) | 一个 **Skill** | [`src/lib/templates/skills/<your-skill>/`](src/lib/templates/skills/) | 一个文件夹,约 3 个文件 |
| 接入一个新的 coding-agent CLI | 一个 **Agent adapter** | [`src/lib/agents/argv.ts`](src/lib/agents/argv.ts) + [`src/lib/agents/detect.ts`](src/lib/agents/detect.ts) | 一个数组里加 10 行 |
| 加一个新的发布目标(视频号、抖音字幕、Notion …) | 一个 **Export adapter** | [`src/components/drafts-menu.tsx`](src/components/drafts-menu.tsx) + `src/lib/export/` 下的 helper | 一个组件 + 一个 helper |
| 加功能、修 bug、重构流式 parser | 代码 | `src/app/`, `src/lib/`, `src/components/` | 常规 PR |
| 改文档、把某一段翻译到另一种语言、修错别字 | 文档 | `README.md`, `README.zh-CN.md`, 本文 | 一个 PR |
不确定自己的想法属于哪一类?[先开个 issue](https://github.com/nexu-io/html-anything/issues/new),我们帮你定位到对应的目录。
---
## 本地开发
```bash
git clone https://github.com/nexu-io/html-anything.git
cd html-anything
pnpm install
pnpm dev # next dev — http://localhost:3000
pnpm build # next build,发布前验包
```
Node `~20``pnpm` 是硬要求。macOS、Linux、WSL2 是主路径;纯 Windows 应该能跑,但不是主要目标 —— 跑不通请提 issue。
push 之前请确保你**至少有一个 coding-agent CLI 登录好了**`claude login``cursor login``gemini auth` …),这样才能跑端到端生成。涉及流式或 agent 层的 PR,期望附上截图或日志片段,证明它真的跑通了。
> **关于项目形态的说明。** 这是一个普通的 Next.js 16 App Router 项目 —— 没有 daemon、没有 Electron 壳、没有别的常驻进程。所有事都在 `next dev` 里完成:server route 直接 spawn 本地 CLI,把 stdout 作为 SSE 流回浏览器,浏览器 append 进 iframe `srcdoc`。如果你打算引入一个独立的长期进程,**请先开 discussion**。
---
## 加一个新的 Skill
skill 是 [`src/lib/templates/skills/`](src/lib/templates/skills/) 下的一个文件夹,根目录有 `SKILL.md`,遵循 Claude Code 的 [`SKILL.md` 约定][skill] + 我们的扩展 frontmatterpicker 会读这部分)。**不需要在任何地方注册。** 把文件夹放进去,重启 `pnpm dev`picker 里就出现了。
### Skill 目录结构
```text
src/lib/templates/skills/your-skill/
├── SKILL.md # 必需 —— 提示词正文 + frontmatter
├── example.html # 必需 —— 你希望 agent 产出什么样的成品,手写一份
├── assets/ # 可选 —— 字体、图片、可复用 CSS、布局片段
└── references/ # 可选 —— 设计系统片段、agent 应该 Read 的参考资料
```
### `SKILL.md` 写法
```markdown
---
name: your-skill
description: picker 预览里显示的一句话简介。
mode: prototype # prototype | deck | frame | social | office | doc | mockup | vfx
scenario: marketing # design | marketing | engineering | product | finance | hr | sale | personal
surface: desktop # desktop | mobile | A4 | 1080x1920 | 1600x900 | 1920x1080 | …
preview:
type: iframe # iframe | image | deck
thumbnail: docs/screenshots/skills/your-skill.png # 可选,README 精选示例网格会用
design_system:
requires: optional # required | optional | none
featured: false # 想登上 "精选示例" 就置 true
example_prompt: |
三句话的示例 prompt,演示这个 skill 接受什么样的输入。
---
# Your Skill
<一段话写清身份 / 纪律 —— 声音、意图、agent 必须遵守什么>
## 硬约束
- 8 px 基线网格 · 所有间距 / line-height / 字号都是 8 的倍数。
- 中文字体栈:`"Noto Sans SC", "PingFang SC", "Source Han Sans"`;英文:`"Inter", "Manrope"`
- 颜色对比 ≥ 4.5。每个可交互元素都有真实 `:focus` 态。
- 必须使用用户提供的真实数据。禁止 `lorem ipsum`、禁止编造指标、禁止紫色渐变。
## 版式
<结构 / 区块 / 层级,用具体 token —— 尺寸、比例、命名插槽 —— 不要用空泛的审美词>
## "好" 长什么样
- <一句正面例子>
- <再一句>
## "坏" 长什么样
- <一句反面例子,对应反 AI-slop 黑名单>
- <再一句>
```
### Skill PR 合并标准
1. **文件夹里附真实 `example.html`。** 手写一份 —— agent 才有 target 可抄。没附的 PR 直接打回。
2. **`example.html` 能在浏览器里渲染**`pnpm dev` → 选这个 skill → ⌘+Enter → 截图)。截图附 PR。
3. **硬约束写得具体。** "用现代字体" 不是约束。真正的约束长这样:"Inter 96 / 64 / 40 / 24 / 16 px 字号梯度,8px 网格,每页最多两个字重"。
4. **example 里不许有 `lorem ipsum`**。要用占位数据,也得是看起来像真的占位数据。
5. **slug 用小写 + 连字符** —— `deck-swiss-international``social-x-post-card`。和已有的 75 个文件夹保持一致。
6. **vendor 进来的作品,必须保留原始 `LICENSE` 和署名**。比如 [`src/lib/templates/skills/deck-guizang-editorial/`](src/lib/templates/skills/deck-guizang-editorial/) 完整保留了 op7418 的 LICENSE 和署名。
### picker 分组规则
picker 用两个维度组织 skill。**优先用已有的取值**,只有当你的 skill 真的不适合任何已有值时才引入新值:
- **`mode`** —— `prototype` · `deck` · `frame` · `social` · `office` · `doc` · `mockup` · `vfx`
- **`scenario`** —— `design` · `marketing` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`
---
## 加一个新的 coding-agent CLI
接入新 agent(比如某家的 `foo-coder` CLI),就在 [`src/lib/agents/argv.ts`](src/lib/agents/argv.ts) 加一行:
```ts
{
id: 'foo',
name: 'Foo Coder',
bin: 'foo',
detect: { args: ['--version'] },
build: (prompt: string) => ({
args: ['exec', '-p', prompt],
stdin: null, // 如果 CLI 走 stdin,写 'prompt'
}),
stream: 'plain', // 'plain' | 'json-event' | 'claude-stream-json'
}
```
完事。`/api/agents` 会在 `PATH` 上扫到它,顶栏 picker 会出现,chat 流程走同一条 SSE 管道。如果这个 CLI 输出**有类型的事件**(比如 Claude Code 的 `--output-format stream-json`),在 [`src/lib/agents/invoke.ts`](src/lib/agents/invoke.ts) 加 parser,把 `stream` 设成 `'claude-stream-json'`
### Agent adapter PR 合并标准
1. **端到端跑通过一次真实 session。** `pnpm dev` 起服,选你接入的 agent,用任一 skill 的 `example_prompt` 生成一次,把 SSE 日志贴在 PR 描述里。
2. **`PATH` 检测在 macOS / Linux / WSL 都能找到。** 扫描器已经包含 `~/.local/bin` · `~/.bun/bin` · `/opt/homebrew/bin` · `~/.npm-global/bin`;如果你的 CLI 装在别处,把目录加进扫描列表。
3. **README 的 "Supported coding agents" 表格加一行**`README.md``README.zh-CN.md` 都改。
4. **stream parser 复用。** 如果新 CLI 输出和已有 adapter 形状一致,请共用 parser,不要 fork。
---
## 加一个新的发布目标
发布目标分两块:`src/lib/export/` 下的 helper 负责产出字节(`.html` 的字符串、`.png` 的 Blob、粘贴的 `ClipboardItem`),[`src/components/drafts-menu.tsx`](src/components/drafts-menu.tsx) 里加一个菜单项把它接进 UI。
### Export PR 合并标准
1. **目标平台往返测试。** 把产物粘 / 上传到目标平台(公众号编辑器、X 输入框、知乎编辑器),把结果截图贴 PR。
2. **代码里不许有平台凭证。** 需要 API Key 的话,让用户在 settings 里填,仓库不许内置。
3. **产物自包含。** `.html``juice` 内联 CSSPNG 用 `modern-screenshot` 走 2× DPR。
---
## 代码风格
我们对格式不严苛(Prettier on save 就够),但有两条不商量,因为它们直接出现在 prompt 栈和对外 API 里:
1. **TS/TSX 用单引号。** 除非转义太丑,否则字符串一律单引号。仓库已经全员一致 —— 请保持。
2. **代码注释一律英文。** 即使你这个 PR 是把某段翻成中文,**代码注释**也保持英文,方便整库 grep。
此外:
- **不要复读。** `// import the module``// loop through items` 之类的注释是噪音。只在代码无法表达的"为什么"上写注释。
- **`src/` 下用 TypeScript。** 没特别理由不要新增顶层 `.js` 文件。
- **新增顶层依赖**要在 PR 描述里写一段"我们得到了什么 vs 我们打包多了多少字节"。[`package.json`](package.json) 的依赖列表是有意保持小的。
- **push 前跑一次 `pnpm build`**。类型错会卡 merge。
---
## Commit & PR 规范
- **一个 PR 一件事。** 加一个 skill + 重构 SSE parser + 升一个依赖 = 三个 PR。
- **标题用祈使句 + scope。** `add deck-product-launch skill``fix SSE backpressure when CLI hangs``docs: clarify skill frontmatter`
- **正文写"为什么"。** "做了什么"通常 diff 一看就懂;"为什么要做"很少看得出来。
- **挂关联 issue。** 没有 issue 但 PR 又不小?请先开一个,约好这件事值得做之后再写代码。
- **review 期间不要 squash。** 用 fixup commitmerge 时我们会 squash。
- **不要 force-push 共享分支**,除非 reviewer 让你做。
我们不强制 CLA。Apache-2.0 已经覆盖;你的贡献以同一份协议授权。
---
## 报 bug
开 issue 时请附:
- 你跑的命令(确切的 `pnpm dev` 调用,或者你点的 UI 按钮)。
- 选的是哪个 agent CLIClaude Code? Cursor Agent? …)。
- 触发的是哪个 skill。
- 相关的 **server 日志尾部** —— "artifact 没有渲染出来"这类问题,看到 `spawn ENOENT` 或者 CLI 的真实错误,通常 30 秒就能诊断。
- 如果是 UI 问题,附截图。
prompt 栈相关的 bug"agent 输出了紫色渐变 hero`SKILL.md` 里明明禁止了"),请把**完整的 assistant message** 贴进来,方便判断是模型违规还是 prompt 写漏了。
---
## 提问
- 架构问题、设计问题、"这算 bug 还是误用" → [GitHub Discussions](https://github.com/nexu-io/html-anything/discussions)(首选 —— 下一个遇到同样问题的人能搜到)。
- "怎么写一个做 X 的 skill" → 开 discussion。我们回答之后,如果这是一个新的 pattern,会把答案沉淀进这份文档。
---
## 我们不接受的 PR
为了让项目保持聚焦,请不要提以下 PR:
- **内置模型 runtime。** 这个项目押注的就是"你已经有的 CLI 就够了"。不内置 `pi-ai`、不内置 OpenAI key、不内置 model loader、不内置托管推理 proxy。
- **未经讨论就把前端栈换掉。** Next.js 16 App Router + React 19 + Tailwind v4 + TypeScript 是底线。不要换 Astro / Solid / Svelte 等等,除非维护者明确想做这次迁移。
- **把 SSE 流式换成 WebSocket / 长轮询。** SSE + iframe `srcdoc` append 是最简单能做到实时渲染的方案,我们保留它。
- **加 telemetry / analytics / phone-home。** 项目是 local-first。唯一的对外调用是你笔记本上的 agent CLI,以及生成出来的 HTML 自己引用的资源(Tailwind CDN、Google Fonts)。
- **打包二进制**但不附 LICENSE 与署名。
- **加一个 skill,但 `example.html` 是空的 / 占位的 / 一眼模型批量糊出来没审过。** skill 的价值靠 example 撑;空 example 直接打回。
不确定自己的想法合不合适?写代码之前先开 discussion。
---
## 多语言维护
仓库以 parity 方式维护两种语言:英文(`README.md``CONTRIBUTING.md`)与简体中文(`README.zh-CN.md``CONTRIBUTING.zh-CN.md`)。新增 / 改名 skill、agent、export 目标时:
- **两份 README 的表格都要更新。**
- 如果你的改动引入了新的贡献入口或新的合并标准,**两份 CONTRIBUTING 都要同步**。
- **Skill 提示词正文保持源语言。** 不要翻译 `SKILL.md` —— 它是给 agent 读的 prompt 栈的一部分,保持单一源语言能避免提示词 QA 跨语言膨胀。
- server 错误消息、文件名、agent 生成的产物文本都是已知短板,除非 PR 明确把它们纳入改动范围。
---
## License
参与贡献即代表你同意:你的贡献以本仓库的 [Apache-2.0 License](LICENSE) 授权。
vendor 进来的第三方作品保留**原始** LICENSE 与署名 —— 每个 `src/lib/templates/skills/<skill>/` 文件夹里的 `LICENSE` / `README.md` 以它为准。最明显的例子是 [`src/lib/templates/skills/deck-guizang-editorial/`](src/lib/templates/skills/deck-guizang-editorial/),完整保留了 [op7418](https://github.com/op7418) 的原始 LICENSE 与署名。
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+273
View File
@@ -0,0 +1,273 @@
# HTML Anything — 构建进度追踪
> 持久化任务列表,防止 context 丢失。每完成一项就更新此文件。
>
> **当前状态(v5 / 2026-05**: 75 个 skill 模板(13 类 / 9 大交付场景)· 17 种 coding agent 自动检测(其中 8 种 stdin 协议立即可用,9 种 ACP/pi-rpc 协议适配中)· dev server 默认端口 `:3000`README / CONTRIBUTING 口径),开发可 `PORT=3021 pnpm dev` 自定义。
>
> *以下早期 v1 状态记录保留作为历史,反映项目从 9 个内置模板演进到 75 个 skill 注册表的轨迹。*
> 最近更新: **全部 11 项任务完成 ✅** — 端到端跑通, dev server 在 :3456
## 项目目标
把任意数据/文档(文本、Markdown、CSV、Excel、JSON、SQL、图片)转成"世界级好看"的 HTML
PPT、简历、海报、知识卡片、社媒图、网页原型、文章、Hyperframes 视频脚本……
然后一键复制成公众号/推特/知乎可发布格式,或截图导出。
灵感来源: Claude Code 创始人不再用 markdown 全用 HTML 表达 → 我们做 "everything → HTML"。
## 全局任务清单
| # | 任务 | 状态 | 备注 |
|---|------|------|------|
| 1 | 研究参考项目核心特性 | ✅ | open-design agent 检测 / markdown-nice 复制 / markdown-to-image 截图 都已研究 |
| 2 | 初始化 Next.js 项目骨架 | ✅ | Next.js 16 + React 19 + Tailwind v4 + Turbopack |
| 3 | 实现本地 Code Agent 检测 API | ✅ | detect.ts + argv.ts + invoke.ts + /api/agents + /api/convert SSE |
| 4 | 构建编辑器/预览主界面布局 | ✅ | toolbar + agent picker + template picker + editor pane + preview pane + export menu |
| 5 | 实现多种数据格式输入处理 | ✅ | auto detect (md/csv/tsv/json/yaml/sql/html) + xlsx 解析 + 图片 dataUrl + 文件上传 |
| 6 | 设计多套世界级 HTML 模板/设计系统 | ✅ | 9 个模板: 文章/PPT/简历/海报/小红书/推特/Web 原型/数据报告/Hyperframes |
| 7 | 实现 AI 转换流水线 | ✅ | spawn agent → SSE → 客户端 hook → iframe 实时刷新 (Claude 测试通过 80s/31KB) |
| 8 | 实现一键复制到公众号/推特/知乎 | ✅ | juice 内联 + ClipboardItem + 知乎数学公式处理 + Safari fallback |
| 9 | 实现 HTML 截图/导出/分享 | ✅ | modern-screenshot 2x PNG / 复制到剪贴板 / 下载 .html / .png |
| 10 | 编写有传播力的 README | ✅ | 中英双语, 含 architecture/templates/agents 表/致谢 |
| 11 | 端到端验证整个流程跑通 | ✅ | curl POST /api/convert → claude → SSE → 完整 HTML 输出, 0 错误 |
## 技术决策
- **框架**: Next.js 16 App Router + Server Components 边界控制
- **样式**: Tailwind v4 (无配置文件, CSS-first)
- **编辑器**: 简洁 textarea + 标签页(文本/MD/CSV/Excel/JSON/上传),不引入 Monaco
- **预览**: iframe srcdoc 沙箱,每次转换后注入完整 HTML
- **状态**: zustand (客户端) + RSC fetch
- **AI 调用**: 优先本地 CLI agentspawn + stdin pipe + SSE relay),可选 API key 兜底
- **复制**: juice 内联样式 → ClipboardItem({'text/html', 'text/plain'})
- **截图**: modern-screenshot domToBlob (image/png) → 剪贴板或下载
- **模板**: 每个模板 = name + category + aspectRatio + designTokens + promptTemplate
- 提示词中包含 Tailwind CDN 引导 + 字体 + 配色 + 排版规则 → AI 输出自包含 HTML
## 文件结构(计划)
```
src/
├── app/
│ ├── api/
│ │ ├── agents/route.ts ✅ GET 检测 agent
│ │ └── convert/route.ts ⏳ POST 流式转换
│ ├── layout.tsx ⏳ 改 metadata + 字体
│ ├── page.tsx ⏳ 主编辑器
│ └── globals.css ⏳ 主题
├── components/
│ ├── editor-pane.tsx
│ ├── preview-pane.tsx
│ ├── toolbar.tsx
│ ├── agent-picker.tsx
│ ├── template-picker.tsx
│ ├── export-menu.tsx
│ └── upload-dropzone.tsx
├── lib/
│ ├── agents/
│ │ ├── detect.ts ✅
│ │ ├── argv.ts ✅
│ │ └── invoke.ts ⏳ spawn helper
│ ├── parsers/
│ │ ├── csv.ts ⏳
│ │ ├── xlsx.ts ⏳
│ │ └── auto.ts ⏳ 自动判断输入类型
│ ├── templates/
│ │ ├── index.ts ⏳
│ │ ├── article.ts
│ │ ├── ppt.ts
│ │ ├── resume.ts
│ │ ├── poster.ts
│ │ ├── xiaohongshu.ts
│ │ ├── twitter-card.ts
│ │ ├── prototype.ts
│ │ └── hyperframes.ts
│ ├── export/
│ │ ├── wechat.ts ⏳
│ │ ├── twitter.ts
│ │ ├── zhihu.ts
│ │ └── image.ts
│ └── store.ts ⏳ zustand
├── PROGRESS.md ✅ 本文件
└── README.md ⏳
```
## 待办(细化) — 全部 ✅
- [x] task-1 研究 (open-design / markdown-nice / markdown-to-image)
- [x] task-2 init Next.js 16 + Tailwind v4 + Turbopack
- [x] task-3.1 detect.ts (PATH 扫描 + 16 个 agent 定义)
- [x] task-3.2 argv.ts (8 个 agent 的 headless 调用参数 + 解析器)
- [x] task-3.3 /api/agents/route.ts
- [x] task-3.4 invoke.ts (spawn + stream + 防御性 close 处理)
- [x] task-4.1 store.ts (zustand + persist)
- [x] task-4.2 主界面布局 (toolbar + 50/50 split)
- [x] task-4.3 toolbar (logo + agent + template + convert + export + ⌘+Enter)
- [x] task-4.4 agent-picker (auto detect + refresh + 已安装/未安装分组)
- [x] task-4.4 template-picker (9 个模板 emoji + 尺寸提示)
- [x] task-4.5 editor-pane (输入/上传/示例 三 tab + 自动格式识别)
- [x] task-4.5 preview-pane (预览/代码/日志 三 tab + status badge)
- [x] task-5.1 parsers/auto.ts (md/csv/tsv/json/yaml/sql/html 自动识别)
- [x] task-5.2 parsers/file.ts (xlsx 解析 + 图片 dataUrl + 文本)
- [x] task-5.2 upload-dropzone (拖拽 + 点击)
- [x] task-6.1 templates/index.ts 注册表
- [x] task-6.2 9 个模板(高质量提示词 + 设计系统约束)
- [x] task-7.1 /api/convert/route.ts (SSE relay)
- [x] task-7.2 use-convert.ts (客户端 SSE 解析 + 流式 append)
- [x] task-7.3 extract-html.ts (从 chatty agent 输出抽取 HTML)
- [x] task-8.1 export/wechat.ts (juice 内联)
- [x] task-8.2 export/zhihu.ts (math → data-eeimg)
- [x] task-8.3 export/image.ts (modern-screenshot 2x PNG)
- [x] task-8.4 export/clipboard.ts (ClipboardItem + Safari fallback)
- [x] task-8.5 export-menu UI (7 个动作: 公众号/知乎/推特/HTML/纯文本/.html/.png)
- [x] task-9 download.ts (.html / .png)
- [x] task-10 README 中英文 + agent 表 + 模板表 + architecture 图
- [x] task-11 dev server :3456 + 真实 claude 调用通过
## 风险记录
- Tailwind v4 + 在 iframe 预览中需要 CDN 注入(每次转换的 HTML 中包含 Tailwind play CDN)✅ 已在所有模板提示词中要求
- 本地 agent 不存在时需引导用户 → 显示安装指引(claude/cursor/codex)✅ agent-picker 显示"未检测到", 未安装条目 disabled 显示
- HTML 通过 iframe srcdoc 注入: 注意 XSS, 严格沙箱 ✅ `sandbox="allow-scripts allow-same-origin"`, 没有给到 top-frame
- Iframe srcdoc 在流式更新时会刷新 → 当前是接受的代价 (claude 大约 2-3 个 chunk, 不是字符级流), 后续可加 debounce
## 验证记录 (2026-05-10 23:30)
- `pnpm exec tsc --noEmit` ✅ 0 errors
- `GET / 200` (295ms, SSR + Tailwind v4 OK)
- `GET /api/agents 200` → 检测到 7 个本地 agent (claude/codex/cursor-agent/gemini/copilot/opencode/aider)
- `POST /api/convert` (claude, article-magazine 模板) → 80s 内返回完整 SSE 流, 4 个事件 (start + 2 deltas + done), 31KB HTML
- 生成的 HTML: 自包含 Tailwind CDN + Google Fonts (Inter/Manrope/Noto Sans/Serif SC/JetBrains Mono) + 自定义 theme + 真实文章排版
## 启动方式
```bash
cd /Users/pftom/.superset/projects/html-everthing
pnpm dev # 默认 :3000
# 或 PORT=3456 pnpm dev
```
## v4 升级 (2026-05-11) — 模型选择 + 首次入口流程
用户反馈: 首次进来如果没配置过 agent, 应该自动弹框选择 + 配置, 包括能选对应 CLI 的模型.
页面入口的 auto-popup 之前已经在 `page.tsx:24` 实现 (`!welcomeAck || !selectedAgent → setOpen(true)`); 本次补齐**模型选择** + **协议感知**.
### 关键改动文件
- `src/lib/agents/detect.ts`
- 新加 `ModelOption` 类型 + `DEFAULT_MODEL` 常量 ("default" → 不传 `--model`, 让 CLI 自己挑)
- 17 个 agent 全部带 `fallbackModels`, 参考 open-design 的 `apps/daemon/src/agents.ts` 的 evidence-based 列表
- `DetectedAgent` 暴露 `protocol` + `models` + `unsupported`, 客户端一次拉到所有 picker 数据
- `src/lib/store.ts` — 新 `agentModels: Record<string, string>` 持久化每个 agent 的最近选择;`AgentInfo` 类型同步加 `protocol/models/unsupported` 字段;persist v2 → v3, 加 `setAgentModel(agent, model)` setter
- `src/components/welcome-modal.tsx`
-`ModelPicker` 子组件 — 选中 agent 后渲染圆角 chip 列表, 点击切换并写入 `agentModels[id]`
-`PROTOCOL_HINT` — 在 agent card 上加 `stdin · stream` / `positional argv` / `ACP JSON-RPC · 暂未接入` / `pi-rpc · 暂未接入` 标签
- 选中 ACP/pi-rpc 时 footer 显红色提示 + "进入编辑器" 按钮 disabled (不阻止用户继续浏览, 但拒绝带未支持 agent 进入流程)
- `src/lib/use-convert.ts``ConvertReq``model`, payload 中按需 `{model: ...}`, log 行包含模型名
- `src/components/toolbar.tsx`
- 从 store 读 `agentModels[selectedAgent]`, 透传到 `run()`
- agent button 增加 mono 风格 model badge (default 时不显示)
- `canConvert` 现在也排除 `agentInfo?.unsupported` 的情形, 配套 friendly tooltip
### 验证
- `pnpm exec tsc --noEmit` ✅ 0 errors in agents/store/use-convert/toolbar/welcome-modal
- `GET /api/agents` → 17 个 agent, 每个含 1-9 个 model, protocol 字段正确 (stdin × 9 / argv × 1 / acp × 6 / pi-rpc × 1), unsupported 标记齐
- `POST /api/convert {agent:"claude", model:"haiku", ...}` → spawn argv 末尾确实多出 `--model haiku`
- 首次访问 (清 localStorage) → modal 自动弹出, 选 agent → 出现 model chip 行 → 选 sonnet → 进入编辑器后 toolbar 显示 `Claude Code [sonnet] `
## v3 升级 (2026-05-11) — 全量 code agent 接入
参考 open-design 的 `apps/daemon/src/agents.ts`,把代理矩阵从 8 → 17:
| # | agent | bin | 协议 | 备注 |
|---|-------|-----|------|------|
| ✅ | claude | `claude` (fb: openclaude/openclaw) | stdin · stream-json | 加 openclaw fork 入口 |
| ✅ | codex | `codex` | stdin · json | 既有 |
| ✅ | cursor-agent | `cursor-agent` | stdin · stream-json | 既有 |
| ✅ | gemini | `gemini` | stdin · stream-json | 既有 |
| ✅ | copilot | `copilot` | stdin · json | 既有 |
| ✅ | opencode | `opencode-cli`/`opencode` | stdin · json | 既有 |
| ✅ | qwen | `qwen` | stdin · plain | 既有 |
| ✅ | aider | `aider` | stdin · plain | 既有 |
| ✅ | qoder | `qodercli` | stdin · stream-json | 新, 复用 claude 风格 envelope 解析 |
| ✅ | deepseek | `deepseek` | **argv** · plain | 新, prompt 走位置参数 (`exec --auto <prompt>`); invoke.ts 加 `protocol:"argv"` 分支 |
| ⚠️ | hermes | `hermes` | ACP JSON-RPC | 检测+安装提示,调用时返回 `UnsupportedAgentProtocolError` |
| ⚠️ | kimi | `kimi` | ACP JSON-RPC | 同上 |
| ⚠️ | devin | `devin` | ACP JSON-RPC | 同上 |
| ⚠️ | kiro | `kiro-cli` | ACP JSON-RPC | 同上 |
| ⚠️ | kilo | `kilo` | ACP JSON-RPC | 同上 |
| ⚠️ | vibe | `vibe-acp` | ACP JSON-RPC | 同上 |
| ⚠️ | pi | `pi` | pi-rpc (Inflection 自有 RPC) | 同上 |
### 关键改动文件
- `src/lib/agents/detect.ts` — 加 `AgentProtocol` 类型 (stdin/argv/acp/pi-rpc); 注册 9 个新 agent; claude 加 `openclaw` fallback
- `src/lib/agents/argv.ts` — 加 `UnsupportedAgentProtocolError`; `buildArgv` 新增 qoder/deepseek 分支 + 6 个 ACP agent + pi 全部抛 friendly error; `parseLine` 处理 qoder envelope + 把 deepseek 视作 plain text (同 aider)
- `src/lib/agents/invoke.ts``protocol:"argv"` 时把 `opts.prompt` 追加到 argv 末尾,并跳过 stdin 写入;捕获 `UnsupportedAgentProtocolError` 转 SSE error event
- `src/components/welcome-modal.tsx``VENDOR_HINT` 加 9 个 vendor (DeepSeek/Cognition/Mature/Moonshot/Inflection/AWS/Kilo/Mistral/Qoder), 每个含 gradient 配色 + 安装命令
### 验证
- `pnpm exec tsc --noEmit` ✅ 0 errors in agents/* and welcome-modal (其它 store/toolbar 错误为 v2 task-list 重构遗留, 与本次无关)
- `GET /api/agents` → 17 个 agent, 本机 10 个已安装 (claude/codex/cursor-agent/gemini/copilot/opencode/deepseek/aider/hermes/kiro)
- `POST /api/convert {agent:"hermes",...}` → SSE 立即返回 `{"type":"error","message":"hermes uses the ACP JSON-RPC protocol, which is not yet wired up..."}`
- `POST /api/convert {agent:"pi",...}` → 未安装时报 `not installed or not on PATH`
## v2 升级 (2026-05-11) — 用户反馈后
| # | 任务 | 状态 | 说明 |
|---|------|------|------|
| 12 | 增强 verbose 日志 | ✅ | DELTA/META/START/STDERR/RAW/DONE/ERROR 7 类彩色 badge + 时间戳 + +elapsed; meta 含 model/session/cwd/usage/cost/duration |
| 13 | 进入页 agent 选择弹框 | ✅ | 居中 modal, 已安装/未安装分组卡片, 含 vendor 渐变色块 + 安装命令提示 + pulse-dot |
| 14 | 整体样式 1:1 open-design | ✅ | paper/bone/ink/coral 调色 + Inter Tight + Playfair italic 强调 + 圆角 pill + paper-grain 背景 |
| 15 | 代码 + 预览实时流式 | ✅ | claude 加 --include-partial-messages → 150 deltas (vs 旧 2 chunks); 代码 tab 自动 scroll + ▍ 光标; iframe 320ms 防抖 |
### 关键改动文件
- `src/lib/agents/argv.ts` — 加 `--include-partial-messages` 给 claude; 新 `parseLine()` 抽出 delta + meta (model/session/usage/cost/duration/rate_limit)
- `src/lib/agents/invoke.ts` — emit `meta` / `raw` / `start.promptBytes` 事件
- `src/lib/use-convert.ts` — 客户端解析 meta, 写入 `stats` (model/tokens/cost/ttfb)
- `src/lib/store.ts` — 新 `RunStats` + 结构化 `LogEntry` (kind/elapsed/data)
- `src/components/welcome-modal.tsx` — 新建, 1:1 open-design 风格
- `src/components/toolbar.tsx` — agent button 改 pill 形式 + 实时 stats
- `src/components/preview-pane.tsx` — 代码 tab 流式 + 光标; debouncedHtml 320ms; LogPanel 7 色 badge
- `src/components/template-picker.tsx` — 改成卡片下拉
- `src/components/export-menu.tsx` — 三段式分组
- `src/app/globals.css` — 完整 open-design 调色板 + .pill / .btn-primary / .btn-ghost / .btn-ink / .od-card / .pulse-dot / .serif-em
- `src/app/layout.tsx` — Inter / Inter Tight / Playfair Display / JetBrains Mono 4 套字体
- `src/app/page.tsx` — 挂载 WelcomeModal, hydration-safe
## 所有参考的项目地址+原始 query
我要做一个任意数据文档可以转 HTML 的一个产品,一个编辑器产品。它核心可以完成这么几个事情:
首先一进来可以去识别用户的本地的 code agent.
然后可以选择某个 code agent, 这是第一个。第二个的话就是说它左侧是一个编辑器或者是 upload
的一个入口,然后右侧是一个 HTML 的预览。然后呢左侧的话可以去直接贴一些文本,无论是 markdown
的纯文本还是一些 CSV、excel 等数据,对或者是一些 CQ 等 database,然后可以直接贴进去,或者是有一个上传入口
可以上传进去。然后上点完之后呢,又点完一个转换之后呢,就可以调 AI,然后去转成右侧的 HTML。这第二个然后第 3
个的话就是右边那个 HTML,它可以去选择不同的 design system 或者是好看的模板。然后可以结合一套完整的提示词
加流程,把这个左侧的这些数据或者是文档转成右侧,按照这个模板好看的 HTML,然后这个 HTML 可以是 PPT
或者是原型/或者通过 html 可以表达的内容,比如简历/hyperframes 视频等的形态对。然后呢,这些转换之后的 HTML
可以一键复制成公众号、推特或者是知乎等格式的发布的数据的形态,然后可以复制到剪贴板。
参考如下这几个项目完成工作:
- 编辑器/html 预览/复制到推特/公众号知乎等格式参考:https://github.com/mdnice/markdown-nice
- 各种 design
system/examples/模板等/形态(ppt/网页/简历等,或者你可以拓展很多有价值的场景,比如海报/各种社媒配图等),
这个参考 https://github.com/nexu-io/open-design https://github.com/mdnice/markdown-resume
https://github.com/jimliu/baoyu-skills https://github.com/gcui-art/markdown-to-image
- 识别本地 code agent 参考 https://github.com/nexu-io/open-design
- 转成 hyperframes 视频/remotion 视频:参见https://github.com/remotion-dev/remotion
https://github.com/heygen-com/hyperframes https://github.com/nexu-io/open-design
需要仔细检查最后有没有跑完并完成整个工作,整体的目标就是 everthing 都可以表达为 html,然后被表达为 html
可以表达的任何内容,核心是一定要好看/世界级设计水准/让用户易于传播
使用 nextjs 实现项目,用户要做就是识别 agent,填入内容或者上传内容,然后转化(调用
agent),然后下载/分享等过程一定要易用,快捷,有价值
搞定之后,可以 写个 readme, 参考 https://github.com/nexu-io/open-design ,
有很多技术/例子/图片等,让用户觉得非常有价值能宣传和传播
这个项目的缘由是 claude code 创始人发了一篇文章,说他们现在不用 markdown,全部用 html 表达了,我们希望呈现
everhting 到 html ,以及能做的事情,传播和吃热度 https://x.com/AlchainHust/status/2053138568818684101
https://github.com/alchaincyf/huashu-md-html https://x.com/trq212/status/2052809885763747935
可以先写个 plan+todo 列表,然后围绕 todo 列表一遍遍检查,直到所有 todo
都勾掉确保能够完成跑通直接可以使用,我明天早上过来检查你的任务
+520
View File
@@ -0,0 +1,520 @@
# HTML Anything
<p align="center"><sub>From the team behind <a href="https://github.com/nexu-io/open-design"><b>Open Design</b></a> — <b>40k★ · 200+ contributors</b>, production-grade and iterating faster. html-anything is the focused agent-era HTML editor; if it clicks for you, <a href="https://github.com/nexu-io/open-design">Open Design</a> is where the same team ships at scale.</sub></p>
<p align="center"><b>Live page:</b> <a href="https://open-design.ai/html-anything/"><b>open-design.ai/html-anything/</b></a> — overview, surface modes, and showcase before you clone.</p>
> **Markdown is the draft. HTML is what humans read. Your local agent writes it.** The agentic HTML editor — in the agentic era, you don't hand-edit docs anymore, so the output format should be what the reader actually wants: HTML. Local-first, zero API key, reuses the CLI session you already have logged in — **9 coding-agent CLIs** auto-detected on your `PATH` (Claude Code · Cursor Agent · Codex · Gemini CLI · GitHub Copilot CLI · OpenCode · Qwen Coder · Aider · IBM Bob), driven by **75 composable skill templates** across **9 deliverable surfaces** (magazine articles · keynote decks · résumés · posters · Xiaohongshu cards · tweet cards · web prototypes · data reports · Hyperframes videos). One-click export to WeChat / X / Zhihu, or download `.html` / `.png`.
<p align="center">
<img src="docs/assets/banner.png" alt="HTML Anything — the agentic HTML editor, on your laptop" width="100%" />
</p>
<p align="center">
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a>
<a href="#supported-coding-agents"><img alt="Agents" src="https://img.shields.io/badge/agents-9%20CLIs-black?style=flat-square" /></a>
<a href="#skills"><img alt="Skills" src="https://img.shields.io/badge/skills-75-orange?style=flat-square" /></a>
<a href="#export-targets"><img alt="Export" src="https://img.shields.io/badge/export-WeChat%20%C2%B7%20X%20%C2%B7%20Zhihu%20%C2%B7%20PNG-9b59b6?style=flat-square" /></a>
<a href="#quickstart"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-30%20seconds-green?style=flat-square" /></a>
<a href="#architecture"><img alt="No API key" src="https://img.shields.io/badge/no-API%20key%20required-ff6b35?style=flat-square" /></a>
</p>
<!-- This project is built on top of nexu-io/open-design — the badges below link to its community channels on purpose. -->
<p align="center">
<a href="https://discord.gg/keeVPMrueT"><img alt="Discord (html-anything)" src="https://img.shields.io/badge/discord-html--anything-5865f2?style=flat-square&logo=discord&logoColor=white" /></a>
<a href="https://x.com/nexudotio"><img alt="Follow @nexudotio on X" src="https://img.shields.io/badge/follow-%40nexudotio-000000?style=flat-square&logo=x&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/releases/latest"><img alt="open-design release" src="https://img.shields.io/github/v/release/nexu-io/open-design?style=flat-square&label=release&color=8e44ad" /></a>
<a href="https://github.com/nexu-io/open-design/graphs/commit-activity"><img alt="open-design commits / month" src="https://img.shields.io/github/commit-activity/m/nexu-io/open-design?style=flat-square&label=commits%2Fmonth&color=f39c12" /></a>
<a href="#showcase"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-9-1abc9c?style=flat-square" /></a>
<a href="https://github.com/nexu-io/open-design"><img alt="Built on open-design" src="https://img.shields.io/badge/built%20on-nexu--io%2Fopen--design-ff7043?style=flat-square&logo=github&logoColor=white" /></a>
</p>
<p align="center"><b>English</b> · <a href="README.zh-CN.md">简体中文</a></p>
---
## Showcase
The eight skills that surface at the top of the picker's **Featured / 推荐** group — sorted by their `recommended:` rank in `SKILL.md` frontmatter (lower = higher). Each ships a real `example.html` you can open straight from the repo, no auth, no setup.
<table>
<tr>
<td width="50%" valign="top">
<a href="next/src/lib/templates/skills/deck-guizang-editorial/"><img src="docs/screenshots/skills/deck-guizang-editorial.png" alt="deck-guizang-editorial" /></a><br/>
<sub><b><a href="next/src/lib/templates/skills/deck-guizang-editorial/"><code>deck-guizang-editorial</code></a></b> · <i>deck</i> · <code>recommended: 1</code><br/>Magazine × e-ink editorial deck, inspired by <a href="https://github.com/op7418/guizang-ppt-skill"><code>op7418/guizang-ppt-skill</code></a> — 10 locked layouts × 5 palettes (Ink / Indigo Porcelain / Forest Ink / Kraft / Dune). Reads like a printed art-zine, not a slide deck.</sub>
</td>
<td width="50%" valign="top">
<a href="next/src/lib/templates/skills/deck-swiss-international/"><img src="docs/screenshots/skills/deck-swiss-international.png" alt="deck-swiss-international" /></a><br/>
<sub><b><a href="next/src/lib/templates/skills/deck-swiss-international/"><code>deck-swiss-international</code></a></b> · <i>deck</i> · <code>recommended: 2</code><br/>Swiss International deck — 16-column grid + one saturated accent (Klein Blue / Lemon / Mint / Safety Orange) across 22 locked layouts. Cold, rational, institutional. The deck that reads "a designer made this" the moment it opens.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="next/src/lib/templates/skills/doc-kami-parchment/"><img src="docs/screenshots/skills/doc-kami-parchment.png" alt="doc-kami-parchment" /></a><br/>
<sub><b><a href="next/src/lib/templates/skills/doc-kami-parchment/"><code>doc-kami-parchment</code></a></b> · <i>doc</i> · <code>recommended: 3</code><br/>Warm-parchment editorial document, inspired by <a href="https://github.com/tw93/kami"><code>tw93/kami</code></a>. <code>#f5f4ed</code> ground + ink-blue accent + single serif voice — a noticeably calmer reading surface than plain-white markdown for long essays, reports, and one-pagers.</sub>
</td>
<td width="50%" valign="top">
<a href="next/src/lib/templates/skills/magazine-poster/"><img src="docs/screenshots/skills/magazine-poster.png" alt="magazine-poster" /></a><br/>
<sub><b><a href="next/src/lib/templates/skills/magazine-poster/"><code>magazine-poster</code></a></b> · <i>poster</i> · <code>recommended: 4</code><br/>Newsprint Sunday-paper poster — oversized serif headline, two-column body, six numbered sections, dot-pattern cream ground. Reads like a printed broadsheet, not a webpage.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="next/src/lib/templates/skills/video-hyperframes/"><img src="docs/screenshots/skills/video-hyperframes.png" alt="video-hyperframes" /></a><br/>
<sub><b><a href="next/src/lib/templates/skills/video-hyperframes/"><code>video-hyperframes</code></a></b> · <i>frame / video</i> · <code>recommended: 5</code><br/>Hyperframes / Remotion-compatible storyboard — 610 sequential <code>1920×1080</code> frames with hidden duration + transition markers and an auto-play script. Hand straight to <a href="https://github.com/heygen-com/hyperframes"><code>heygen-com/hyperframes</code></a> or Remotion to render <code>.mp4</code>.</sub>
</td>
<td width="50%" valign="top">
<a href="next/src/lib/templates/skills/frame-glitch-title/"><img src="docs/screenshots/skills/frame-glitch-title.png" alt="frame-glitch-title" /></a><br/>
<sub><b><a href="next/src/lib/templates/skills/frame-glitch-title/"><code>frame-glitch-title</code></a></b> · <i>frame</i> · <code>recommended: 6</code><br/>Glitch title frame — cyan/magenta chromatic offset, CRT scanlines, corrupted-data subtitle, ASCII noise in the corners. Cyberpunk hero card or video transition.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="next/src/lib/templates/skills/vfx-text-cursor/"><img src="docs/screenshots/skills/vfx-text-cursor.png" alt="vfx-text-cursor" /></a><br/>
<sub><b><a href="next/src/lib/templates/skills/vfx-text-cursor/"><code>vfx-text-cursor</code></a></b> · <i>vfx</i> · <code>recommended: 7</code><br/>VFX text-cursor opener — a cursor "types" across the canvas, each character revealed with a hot-pink × cyan chromatic trail and directional light leaks. Drop in a quote, get a film-grade opening frame.</sub>
</td>
<td width="50%" valign="top">
<a href="next/src/lib/templates/skills/frame-logo-outro/"><img src="docs/screenshots/skills/frame-logo-outro.png" alt="frame-logo-outro" /></a><br/>
<sub><b><a href="next/src/lib/templates/skills/frame-logo-outro/"><code>frame-logo-outro</code></a></b> · <i>frame</i> · <code>recommended: 8</code><br/>Logo outro frame — logo assembles piece-by-piece with glow bloom, tagline rises, CTA appears. The closing card for a product reveal or brand film.</sub>
</td>
</tr>
</table>
The full skill catalog (organized by mode) is in [Skills](#skills) below.
## Why this exists
Anthropic's [Claude Code team announced](https://x.com/trq212/status/2052809885763747935) they stopped writing internal docs in Markdown — they ship HTML now. The argument is simple:
| Markdown | HTML |
|---|---|
| Good for the writer | Good for the reader |
| Layout limited to the renderer | Layout is yours |
| Looks ugly screenshotted into a tweet | Already looks like a designed image |
| Has to be re-flowed for WeChat / Zhihu / newsletter | One-click format conversion |
**HTML is the final form for humans. Markdown is just an intermediate state during writing.**
But "writing HTML" used to mean writing CSS, picking type scales, snapping to a grid, doing responsive — most users won't, designers won't bother, writers don't have the patience. So what we built: after you press ⌘+Enter, your **local AI agent** turns any input (Markdown / CSV / Excel / JSON / SQL / raw notes) into a **ship-ready single-file HTML** in seconds, then one-click sends it to WeChat / X / Zhihu / anywhere. "Ship-ready" is the bar — when generation finishes, the artifact is what your audience actually sees. No "I'll touch it up later" pass.
We stand on four open-source shoulders:
- [**`nexu-io/open-design`**](https://github.com/nexu-io/open-design) — the agent-detection layer, the design-system model, and the `SKILL.md` protocol. `next/src/lib/agents/` and `next/src/lib/templates/skills/*` mirror this architecture directly.
- [**`mdnice/markdown-nice`**](https://github.com/mdnice/markdown-nice) — proof that `juice`-inlined CSS pastes cleanly into WeChat and Zhihu without per-platform manual fix-up.
- [**`gcui-art/markdown-to-image`**](https://github.com/gcui-art/markdown-to-image) — the iframe → high-DPI PNG export path.
- [**`alchaincyf/huashu-md-html`**](https://github.com/alchaincyf/huashu-md-html) — the anti-AI-slop discipline that maps into the hard constraints inside every `SKILL.md` (CJK-first font stack, 8 px baseline grid, contrast ≥ 4.5, must-use-real-data rule).
## At a glance
| | What you get |
|---|---|
| **Coding-agent CLIs (8)** | Claude Code · Cursor Agent · OpenAI Codex · Gemini CLI · GitHub Copilot CLI · OpenCode · Qwen Coder · Aider — scanned on startup across `PATH` (including `~/.local/bin`, `~/.bun/bin`, `/opt/homebrew/bin`, `~/.npm-global/bin` — directories a GUI-launched Node process normally misses), swap from the top-bar picker. |
| **Zero API key** | We reuse the session you already have logged in via `claude login` · `cursor login` · `gemini auth`. Your existing subscription does the work; marginal cost is **$0**. |
| **75 skill templates** | `prototype` (web / SaaS landing / dashboard / data report) · `deck` (20 keynote skills incl. Swiss International, Guizang Editorial, XHS Pastel, Hermes Cyber, Replit, Magazine Web…) · `frame` (10 Hyperframes video frames — liquid hero, NYT data chart, sticky-note flowchart, glitch title, cinema light-leak, macOS notification, logo outro…) · `social` (X / Xiaohongshu / Spotify / Reddit cards) · `office` (PM spec · eng runbook · finance report · HR onboarding · invoice · OKRs · weekly update · meeting notes · kanban) · `doc` (Kami warm-parchment editorial) · `mockup` (3D device frame) · `vfx` (text-cursor effect). |
| **9 surface modes** | 📖 magazine article · 🎬 keynote deck · 📄 résumé · 🖼️ poster · 📱 Xiaohongshu card · 🐦 tweet card · 🛠️ web prototype · 📊 data report · 🎞️ Hyperframes video. Each has multiple skills you can pick from. |
| **One-click export** | `juice` inlines CSS → WeChat paste with zero re-formatting · `modern-screenshot` renders the iframe to a 2× PNG → `ClipboardItem` → drop straight into the tweet composer · `<mjx-container>``data-eeimg` placeholder → Zhihu equations render automatically · standalone `.html` download · high-DPI `.png` download. |
| **Streaming render** | `POST /api/convert` over SSE. The agent's stdout JSON-line stream is parsed for text deltas → server-sent events → client appends → iframe `srcdoc` updates live. Waiting for an AI generation looks like watching it type in real time. |
| **Sandboxed preview** | `<iframe sandbox="allow-scripts allow-same-origin">`. User-emitted HTML runs in an isolated origin — Tailwind CDN / Google Fonts / inline scripts work, but cookies and localStorage are quarantined from the host. |
| **Format auto-detect** | The editor accepts Markdown / CSV / TSV / JSON / SQL / plain text. `papaparse` + `xlsx` parse tabular data in the browser — nothing is uploaded. |
| **Deployable to** | Local (`pnpm -F @html-anything/next dev`) · Vercel for the web layer (the agent always stays on your laptop). |
| **License** | Apache-2.0 |
## Demo
<table>
<tr>
<td width="50%">
<img src="docs/screenshots/01-entry-view.png" alt="01 · Entry view" /><br/>
<sub><b>Entry view</b> — the top bar shows your installed CLIs, the left pane is the editor, the middle is the template + design-system picker, the right is a live iframe preview. The same surface produces magazines, decks, posters, web prototypes, and Hyperframes frame scripts.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/02-template-picker.png" alt="02 · 75 skills, searchable and filterable" /><br/>
<sub><b>75 templates, searchable and filterable</b> — pick by mode (prototype / deck / frame / social / office / doc) × scenario (design / marketing / engineering / product / personal). Every skill ships an <code>example.html</code> you can open straight from the repo to see what the agent will produce.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/03-streaming.png" alt="03 · Live SSE streaming" /><br/>
<sub><b>SSE streaming</b> — agent stdout JSON-line is parsed for text deltas, appended into the iframe <code>srcdoc</code> in real time. You watch the page render line by line. Don't like where it's going? Interrupt and re-prompt — no wasted full generation.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/04-export.png" alt="04 · One-click export" /><br/>
<sub><b>One-click export</b> — WeChat (juice-inlined CSS) · X / Weibo / Xiaohongshu (modern-screenshot → 2× PNG → <code>ClipboardItem</code>) · Zhihu (LaTeX image placeholders) · download <code>.html</code> · download <code>.png</code>. Paste straight in, no second pass of manual formatting.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/05-deck-mode.png" alt="05 · Deck mode" /><br/>
<sub><b>Deck mode</b> — 20 keynote skills, including Swiss International (Helvetica grid maximalism), Guizang Editorial (magazine ink), Open Slide Canvas (1920×1080 agent-native), Magazine Web, XHS Pastel, Hermes Cyber, Replit Style. ←/→ to navigate slides, presenter notes, PDF export.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/06-hyperframes.png" alt="06 · Hyperframes video frames" /><br/>
<sub><b>Hyperframes frames</b> — 10 motion frame scripts (liquid hero, NYT data chart, sticky-note flowchart, glitch title, cinema light-leak, macOS notification, brand logo outro, text-cursor VFX, 3D device mockup, …) conforming to <a href="https://github.com/heygen-com/hyperframes">heygen-com/hyperframes</a>; hand off straight to Remotion to render <code>.mp4</code>.</sub>
</td>
</tr>
</table>
## Quickstart
```bash
git clone https://github.com/nexu-io/html-anything
cd html-anything
pnpm install
pnpm -F @html-anything/next dev
# → http://localhost:3000
```
Open the browser → the top bar auto-detects whichever coding-agent CLI you already have signed in → pick a template → paste content → ⌘+Enter.
**No API key required.** We reuse the session you already have logged in (Claude / Cursor / Codex / Gemini / Copilot subscriptions all work).
## Workspace
This repo is a small pnpm workspace:
- `next/` is the complete Next app (`@html-anything/next`).
- `e2e/` is the browser-test package (`@html-anything/e2e`) and the only source of truth for Playwright cases.
- root owns CI, docs, and `scripts/guard.ts`; root `package.json` intentionally does not proxy app or e2e commands.
Run package commands from the repo root:
```bash
pnpm exec tsx scripts/guard.ts
pnpm -F @html-anything/next dev
pnpm -F @html-anything/next typecheck
pnpm -F @html-anything/next test
pnpm -F @html-anything/next build
pnpm -F @html-anything/e2e typecheck
pnpm -F @html-anything/e2e test
```
## Supported coding agents
On startup we scan `PATH` (including `~/.local/bin`, `~/.bun/bin`, `/opt/homebrew/bin`, `~/.npm-global/bin` — directories normally missed by GUI-launched Node) and surface every CLI we recognize:
| Agent | Detection binary | Invocation |
|---|---|---|
| **Claude Code** | `claude` | `claude -p --output-format stream-json` |
| **OpenAI Codex** | `codex` | `codex exec --json --sandbox workspace-write` |
| **Cursor Agent** | `cursor-agent` | `cursor-agent --print --output-format stream-json --force --trust` |
| **Gemini CLI** | `gemini` | `gemini --output-format stream-json --yolo` |
| **GitHub Copilot CLI** | `copilot` | `copilot --allow-all-tools --output-format json` |
| **OpenCode** | `opencode-cli` / `opencode` | `opencode run --format json --dangerously-skip-permissions -` |
| **Qwen Coder** | `qwen` | `qwen --yolo -` |
| **Aider** | `aider` | `aider --no-pretty --no-stream --yes-always --message-file -` |
| **IBM Bob** | `bob` | `bob --output-format stream-json --hide-intermediary-output` |
> The detection strategy and per-CLI adapter shape are borrowed directly from [`nexu-io/open-design`](https://github.com/nexu-io/open-design) and [`multica-ai/multica`](https://github.com/multica-ai/multica): one privileged process spawns CLIs, JSON-line is the wire protocol, every CLI gets a thin adapter in [`next/src/lib/agents/argv.ts`](next/src/lib/agents/argv.ts).
If you've already done `claude login` / `cursor login` / `gemini auth` in your terminal, HTML Anything reuses that session. **No second copy of the API key required.**
## Skills
**75 skills under [`next/src/lib/templates/skills/`](next/src/lib/templates/skills/)**, each a folder following the Claude Code [`SKILL.md`](https://docs.anthropic.com/en/docs/claude-code/skills) convention plus an extended frontmatter (`mode` · `scenario` · `surface` · `preview` · `design_system`).
The two axes the picker uses:
- **mode** — `prototype` (web / SaaS landing / dashboard / data report / résumé / doc) · `deck` (20 horizontal-swipe presentations) · `frame` (10 Hyperframes motion frames) · `social` (4 social-card formats) · `office` (PM / engineering / finance / HR / operations document surfaces).
- **scenario** — `design` · `marketing` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`. Used to group skills in the picker.
### Web prototypes & marketing pages (prototype mode)
| Skill | Best for | Output |
|---|---|---|
| [`prototype-web`](next/src/lib/templates/skills/prototype-web/) | Generic web prototype (default) | Single-page HTML, 1440×900 desktop |
| [`saas-landing`](next/src/lib/templates/skills/saas-landing/) | SaaS landing page | Hero / features / pricing / CTA |
| [`waitlist-page`](next/src/lib/templates/skills/waitlist-page/) | Waitlist / early-access page | Minimal form + social proof |
| [`pricing-page`](next/src/lib/templates/skills/pricing-page/) | Pricing page | Multi-tier comparison tables |
| [`dashboard`](next/src/lib/templates/skills/dashboard/) | Admin / analytics console | Sidebar + dense data layout |
| [`docs-page`](next/src/lib/templates/skills/docs-page/) | Technical documentation | 3-column docs layout |
| [`blog-post`](next/src/lib/templates/skills/blog-post/) | Long-form blog post | Editorial layout |
| [`mobile-app`](next/src/lib/templates/skills/mobile-app/) | iOS / Android prototype | iPhone 15 Pro chrome |
| [`mobile-onboarding`](next/src/lib/templates/skills/mobile-onboarding/) | App onboarding flow | Splash · value · sign-in trio |
| [`gamified-app`](next/src/lib/templates/skills/gamified-app/) | Gamified app | Quest · XP · level trio |
| [`dating-web`](next/src/lib/templates/skills/dating-web/) | Dating / matchmaking dashboard | Left rail · KPIs · 30-day chart |
| [`magazine-poster`](next/src/lib/templates/skills/magazine-poster/) | Single-page magazine poster | 1080×1920 |
| [`poster-hero`](next/src/lib/templates/skills/poster-hero/) | Marketing poster | Single-page display poster |
| [`web-proto-editorial`](next/src/lib/templates/skills/web-proto-editorial/) | Editorial-style web | Serif display + grid |
| [`web-proto-brutalist`](next/src/lib/templates/skills/web-proto-brutalist/) | Brutalist web | Hard edges, black & white, anti-grid |
| [`web-proto-soft`](next/src/lib/templates/skills/web-proto-soft/) | Soft / warm web | Soft shadows, rounded, warm palette |
| [`article-magazine`](next/src/lib/templates/skills/article-magazine/) | Long-form magazine article | A4 / long-page |
| [`digital-eguide`](next/src/lib/templates/skills/digital-eguide/) | Digital e-guide | Cover + lesson spread |
| [`resume-modern`](next/src/lib/templates/skills/resume-modern/) | Minimal résumé | A4 210×297mm |
| [`email-marketing`](next/src/lib/templates/skills/email-marketing/) | Brand product-launch email | Center single-column, table fallback |
| [`wireframe-sketch`](next/src/lib/templates/skills/wireframe-sketch/) | Hand-drawn wireframe | Early-pass ideation |
### Decks (deck mode, 20 skills)
| Skill | Best for |
|---|---|
| [`deck-swiss-international`](next/src/lib/templates/skills/deck-swiss-international/) | Swiss International deck |
| [`deck-guizang-editorial`](next/src/lib/templates/skills/deck-guizang-editorial/) | Editorial-ink deck (from [op7418/guizang-ppt-skill](https://github.com/op7418/guizang-ppt-skill)) |
| [`deck-open-slide-canvas`](next/src/lib/templates/skills/deck-open-slide-canvas/) | 1920×1080 agent-native canvas (from [1weiho/open-slide](https://github.com/1weiho/open-slide)) |
| [`deck-magazine-web`](next/src/lib/templates/skills/deck-magazine-web/) | Magazine-style web deck |
| [`deck-hermes-cyber`](next/src/lib/templates/skills/deck-hermes-cyber/) | Hermes Cyber neon-luxe |
| [`deck-replit`](next/src/lib/templates/skills/deck-replit/) | Replit-style product walkthrough |
| [`deck-xhs-pastel`](next/src/lib/templates/skills/deck-xhs-pastel/) | Xiaohongshu pastel deck |
| [`deck-xhs-white`](next/src/lib/templates/skills/deck-xhs-white/) | Xiaohongshu pure-white deck |
| [`deck-xhs-post`](next/src/lib/templates/skills/deck-xhs-post/) | Xiaohongshu single-post deck |
| [`deck-tech-sharing`](next/src/lib/templates/skills/deck-tech-sharing/) | Tech-sharing deck |
| [`deck-product-launch`](next/src/lib/templates/skills/deck-product-launch/) | Product-launch event deck |
| [`deck-pitch`](next/src/lib/templates/skills/deck-pitch/) | Investor pitch |
| [`deck-blueprint`](next/src/lib/templates/skills/deck-blueprint/) | Blueprint / roadmap |
| [`deck-presenter-mode`](next/src/lib/templates/skills/deck-presenter-mode/) | Speaker-notes-aware |
| [`deck-course-module`](next/src/lib/templates/skills/deck-course-module/) | Course module deck |
| [`deck-dir-key-nav`](next/src/lib/templates/skills/deck-dir-key-nav/) | Directional key-nav deep browse |
| [`deck-graphify-dark`](next/src/lib/templates/skills/deck-graphify-dark/) | Dark, data-graphic-heavy deck |
| [`deck-obsidian-claude`](next/src/lib/templates/skills/deck-obsidian-claude/) | Obsidian / Claude-flavored notes |
| [`deck-safety-alert`](next/src/lib/templates/skills/deck-safety-alert/) | Incident / safety briefing |
| [`deck-simple`](next/src/lib/templates/skills/deck-simple/) | Minimal horizontal-swipe deck |
### Hyperframes video frames & VFX & device mockups (frame / vfx / mockup, 12 skills)
| Skill | Best for |
|---|---|
| [`frame-liquid-bg-hero`](next/src/lib/templates/skills/frame-liquid-bg-hero/) | Liquid background hero |
| [`frame-data-chart-nyt`](next/src/lib/templates/skills/frame-data-chart-nyt/) | NYT-style data chart |
| [`frame-flowchart-sticky`](next/src/lib/templates/skills/frame-flowchart-sticky/) | Sticky-note flowchart |
| [`frame-glitch-title`](next/src/lib/templates/skills/frame-glitch-title/) | Glitch title card |
| [`frame-light-leak-cinema`](next/src/lib/templates/skills/frame-light-leak-cinema/) | Cinema light-leak |
| [`frame-macos-notification`](next/src/lib/templates/skills/frame-macos-notification/) | macOS notification toast |
| [`frame-logo-outro`](next/src/lib/templates/skills/frame-logo-outro/) | Brand logo outro |
| [`motion-frames`](next/src/lib/templates/skills/motion-frames/) | Generic motion-design frame |
| [`video-hyperframes`](next/src/lib/templates/skills/video-hyperframes/) | Hyperframes frame-script schema |
| [`sprite-animation`](next/src/lib/templates/skills/sprite-animation/) | Pixel / 8-bit animation |
| [`vfx-text-cursor`](next/src/lib/templates/skills/vfx-text-cursor/) | Text-cursor VFX |
| [`mockup-device-3d`](next/src/lib/templates/skills/mockup-device-3d/) | 3D device-frame mockup |
> Frame scripts conform to the [`heygen-com/hyperframes`](https://github.com/heygen-com/hyperframes) spec and hand off straight to [`remotion-dev/remotion`](https://github.com/remotion-dev/remotion) to render `.mp4`.
### Social share cards (social mode)
| Skill | Best for |
|---|---|
| [`social-x-post-card`](next/src/lib/templates/skills/social-x-post-card/) | X / Twitter quote card (1600×900) |
| [`social-spotify-card`](next/src/lib/templates/skills/social-spotify-card/) | Spotify-Wrapped style card |
| [`social-reddit-card`](next/src/lib/templates/skills/social-reddit-card/) | Reddit thread card |
| [`social-carousel`](next/src/lib/templates/skills/social-carousel/) | 3-card 1080×1080 carousel |
| [`card-xiaohongshu`](next/src/lib/templates/skills/card-xiaohongshu/) | Xiaohongshu image-with-text card |
| [`card-twitter`](next/src/lib/templates/skills/card-twitter/) | Twitter pull-quote card |
| [`social-media-dashboard`](next/src/lib/templates/skills/social-media-dashboard/) | Social-ops dashboard |
| [`social-media-matrix`](next/src/lib/templates/skills/social-media-matrix/) | Multi-platform content matrix |
### Office & operations (office / doc mode)
| Skill | Scenario | Best for |
|---|---|---|
| [`doc-kami-parchment`](next/src/lib/templates/skills/doc-kami-parchment/) | personal | Warm-parchment editorial doc (from [tw93/kami](https://github.com/tw93/kami)) |
| [`pm-spec`](next/src/lib/templates/skills/pm-spec/) | product | PM spec doc + decision log |
| [`team-okrs`](next/src/lib/templates/skills/team-okrs/) | product | OKR scoresheet |
| [`meeting-notes`](next/src/lib/templates/skills/meeting-notes/) | operation | Meeting decision log |
| [`weekly-update`](next/src/lib/templates/skills/weekly-update/) | operation | Team weekly cadence |
| [`kanban-board`](next/src/lib/templates/skills/kanban-board/) | operation | Board snapshot |
| [`eng-runbook`](next/src/lib/templates/skills/eng-runbook/) | engineering | Incident runbook |
| [`finance-report`](next/src/lib/templates/skills/finance-report/) | finance | Exec finance summary |
| [`invoice`](next/src/lib/templates/skills/invoice/) | finance | Single-page invoice |
| [`hr-onboarding`](next/src/lib/templates/skills/hr-onboarding/) | hr | Role onboarding plan |
| [`data-report`](next/src/lib/templates/skills/data-report/) | finance / product | CSV/Excel → visual data report |
| [`live-dashboard`](next/src/lib/templates/skills/live-dashboard/) | operation | Live data dashboard |
| [`flowai-team-dashboard`](next/src/lib/templates/skills/flowai-team-dashboard/) | operation | Team workflow dashboard |
| [`ppt-keynote`](next/src/lib/templates/skills/ppt-keynote/) | personal | Generic Keynote-style deck |
**Adding a skill takes one folder.** Copy a similar skill, edit its `SKILL.md` frontmatter, restart `pnpm -F @html-anything/next dev`, the picker shows it. See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the bar a skill PR has to clear before we merge.
## Six load-bearing ideas
### 1 · We don't ship an agent. Yours is good enough.
On boot the browser calls `/api/agents`. The server scans `PATH` — including the dirs a GUI-launched Node usually misses (`~/.local/bin`, `~/.bun/bin`, `/opt/homebrew/bin`, `~/.npm-global/bin`) — and surfaces whichever CLIs it finds. Each CLI has one adapter (argv + stdin protocol + stream parser) in [`next/src/lib/agents/argv.ts`](next/src/lib/agents/argv.ts). The whole detection model is borrowed directly from [`nexu-io/open-design`](https://github.com/nexu-io/open-design) and [`multica-ai/multica`](https://github.com/multica-ai/multica): one privileged process spawns CLIs, JSON-line is the wire protocol.
### 2 · Skills are folders, not plugins.
Following Claude Code's [`SKILL.md` convention](https://docs.anthropic.com/en/docs/claude-code/skills) — `SKILL.md` + `assets/` + `references/` + `example.html`. Drop a folder into [`next/src/lib/templates/skills/`](next/src/lib/templates/skills/), restart `pnpm -F @html-anything/next dev`, the picker shows it. `deck-guizang-editorial` is vendored directly from [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) with original LICENSE and authorship preserved.
### 3 · Hard constraints stop the model from freestyling.
Every template hardcodes:
- **CJK-first font stack** — `Noto Sans/Serif SC` / source-han for Chinese, `Inter` / `Manrope` for Latin.
- **8 px baseline grid** — every spacing, line-height, font-size is a multiple of 8.
- **Rounded corners · soft shadow · no pure black / pure white** — visual de-slop-ification.
- **Color contrast ≥ 4.5**, every interactive element has a real `:focus` state.
- **Must use the user's real data**, no lorem ipsum.
The discipline is lifted from [`alchaincyf/huashu-design`](https://github.com/alchaincyf/huashu-design)'s Junior-Designer mode + anti-AI-slop checklist. Constraints belong in the prompt — every `SKILL.md` writes them in.
### 4 · Streaming render = watch the AI draw.
`POST /api/convert` is SSE. The agent's stdout is line-delimited JSON; the server pulls out the text deltas and re-emits them as SSE events; the client appends to the iframe's `srcdoc`. The whole experience is the same as watching the agent type in a terminal, except the artifact is HTML, not Markdown. **Interrupt at any time** — you're not paying for a whole generation you don't want.
### 5 · One-click export = zero second-pass formatting.
- **WeChat MP** — `juice` inlines CSS + `data-tool` markers → paste into the editor, styles survive end to end.
- **X / Weibo / Xiaohongshu** — `modern-screenshot` renders the iframe to a 2× PNG → `ClipboardItem` → drop straight into the composer.
- **Zhihu** — same as above, plus `<mjx-container>` is replaced with `data-eeimg` LaTeX-image placeholders (Zhihu won't render KaTeX live — math has to be an image).
- **Download `.html`** / **download `.png`** — self-contained single file, shareable anywhere.
Mechanically inspired by [`mdnice/markdown-nice`](https://github.com/mdnice/markdown-nice) and [`gcui-art/markdown-to-image`](https://github.com/gcui-art/markdown-to-image).
### 6 · Sandboxed iframe = secure + isolated.
User-emitted HTML always renders inside `<iframe sandbox="allow-scripts allow-same-origin">`. Third-party scripts (Tailwind CDN, Google Fonts, custom animations) still execute, but cookies and localStorage stay in the iframe's own origin — the host page is never poisoned. Opening devtools only shows the iframe's DOM, so the debugging experience matches a standalone HTML file.
## Architecture
```
┌─────────────────────── Browser (Next.js 16) ──────────────────────┐
│ Editor / upload · top-bar agent picker · template picker · iframe │
└─────────────┬──────────────────────────────────┬──────────────────┘
│ ⌘+Enter │
▼ ▼
┌─────────────────────┐ ┌──────────────────────┐
│ GET /api/agents │ │ POST /api/convert │
│ scan PATH, list │ │ SSE — spawn CLI │
│ installed CLIs │ │ pipe stdin / stdout │
└─────────────────────┘ └──────────┬───────────┘
│ spawn + stdin pipe
┌────────────────────────────────────┐
│ Your local coding-agent CLI │
│ claude / codex / cursor-agent / │
│ gemini / copilot / opencode / │
│ qwen / aider │
│ → reuses your existing session │
└────────────────────────────────────┘
stdout JSON-line ──► SSE event
iframe srcdoc append (live)
⌘+C copy → ClipboardItem
⌘+S download → .html / .png
```
| Layer | Stack |
|---|---|
| Frontend | Next.js 16 App Router + Turbopack · React 19 · Tailwind v4 · zustand state |
| Server routes | `GET /api/agents` (detection) · `POST /api/convert` (SSE streaming spawn) |
| Agent transport | `child_process.spawn` · one stdin/stdout adapter per CLI ([`next/src/lib/agents/argv.ts`](next/src/lib/agents/argv.ts)) |
| Browser-side processing | `juice` (CSS inlining) · `modern-screenshot` (PNG export) · `xlsx` / `papaparse` (spreadsheet parsing) · `marked` + `highlight.js` (Markdown-compatible input) · `dompurify` (XSS defense) |
| Preview sandbox | `iframe[sandbox="allow-scripts allow-same-origin"]` + `srcdoc` |
| Export targets | `.html` standalone · `.png` high-DPI · `ClipboardItem` (text/html + image/png) · WeChat-compatible inlined CSS |
| Deploy | Local `pnpm -F @html-anything/next dev` · Vercel one-click for the web layer (agent stays local) |
## Export targets
| Platform | Implementation | Paste behavior |
|---|---|---|
| **WeChat MP** | `juice` inlines CSS + `data-tool` markers | Paste into editor, zero re-formatting |
| **Zhihu** | Same as WeChat + `<mjx-container>``data-eeimg` LaTeX image placeholder | Equations render after upload |
| **X / Weibo / Xiaohongshu** | `modern-screenshot` → 2× PNG → `ClipboardItem` | Drop straight into the composer |
| **Download `.html`** | Single-file, inlined assets | Open anywhere with a browser |
| **Download `.png`** | High-DPI screenshot | Share anywhere |
## Roadmap
- [ ] **Multi-template compare preview** — generate four candidates of the same brief, pick the best one
- [ ] **Hyperframes → mp4** — one-click hand-off of frame scripts to Remotion for real video output
- [ ] **Shared design systems** — interop with [`nexu-io/open-design`](https://github.com/nexu-io/open-design) `DESIGN.md` assets
- [ ] **Template marketplace** — community-contributed prompts & examples
- [ ] **Browser extension** — select on any page → convert to HTML template
- [ ] **History / version diff / IndexedDB archive**
- [ ] **More export targets** — WeChat Channels · Douyin captions · Notion · Linear · Telegraph
## Status
Early but real. The closed loop — **detect agent → pick skill → SSE stream → sandboxed iframe preview → one-click export** — runs end-to-end against all 8 CLIs listed above. The skill library and the `SKILL.md` hard-constraints are where most of the leverage lives, and both are stable. The picker UX, design-system metadata, and the multi-template compare flow ship daily. If something looks broken on your machine, open an issue with the agent CLI you were using and the input — those are the bug reports that move things forward fastest.
| Surface | State |
|---|---|
| Agent detection (8 CLIs) | ✅ stable |
| Skill registry + picker (75 skills) | ✅ stable |
| SSE streaming render | ✅ stable |
| Sandboxed iframe preview | ✅ stable |
| One-click WeChat / X / Zhihu / `.html` / `.png` export | ✅ stable |
| CSV / Excel / JSON / SQL format auto-detect | ✅ stable |
| Multi-template compare (generate 4, pick 1) | 🛠 in progress |
| Hyperframes → `.mp4` one-click handoff to Remotion | 🛠 in progress |
| Browser extension (select on any page → convert) | ⏳ planned |
| History / version diff / IndexedDB archive | ⏳ planned |
| Skill marketplace (`install <github-repo>`) | ⏳ planned |
## Security
The Next API surface is the local-only side of the app — `/api/convert` spawns the user's coding-agent CLI with maximally permissive flags, `/api/deploy` writes credentialed config to disk. Both are intended for a single operator on a single machine. To prevent a malicious page from DNS-rebinding `attacker.example` to `127.0.0.1` and POSTing into those routes through the user's browser, every `/api/*` request is gated on a Host-header allowlist in [`next/src/middleware.ts`](next/src/middleware.ts).
| Setting | When to use |
|---|---|
| **Default (no env vars set)** | The common case — `next dev` / `next start` on your own machine. `127.0.0.1`, `localhost`, and `::1` Host headers (any port) are accepted. Everything else gets a 403 `{ "error": "Host not allowed" }`. |
| **`HTML_ANYTHING_ALLOWED_HOSTS=daemon.mirage.local,html-anything.lan`** | LAN / mDNS setup — you're reaching the app from another device on the same network and the browser dials a non-loopback hostname. Comma-separated; port-insensitive; case-insensitive. The default loopback set is still accepted on top of this. |
| **`HTML_ANYTHING_ALLOW_ANY_HOST=1`** | Reverse-proxy mode — Caddy / nginx / Cloudflare Tunnel is terminating the public hostname and forwarding to the app. The proxy is now responsible for Host policy. Loudly insecure if you set this without a trusted proxy in front, so it is not the default. |
Set the env var in whatever environment file your launcher reads (e.g. `next/.env.local`). The middleware is pinned to the Node runtime (`export const runtime = "nodejs"` in [`next/src/middleware.ts`](next/src/middleware.ts)) so `process.env` is read per-request — Edge middleware can inline `process.env.*` at build time, which would silently break operator overrides set after `next build`. The validation is unit-tested in [`next/src/lib/security/host-validation.test.ts`](next/src/lib/security/host-validation.test.ts) and the loopback-still-works dev path is exercised in the Playwright spec at [`e2e/ui/host-validation.spec.ts`](e2e/ui/host-validation.spec.ts).
## Contributing
Issues, PRs, new skills, new agent adapters, new export targets, and translations are all welcome. The highest-leverage contributions are usually **one folder, one Markdown file, or one PR-sized adapter** — small surface area, big leverage. Pick the slot that matches what you want to add:
- **Add a skill** — drop a folder into [`next/src/lib/templates/skills/`](next/src/lib/templates/skills/) with `SKILL.md` + `example.html` (+ optional `assets/` and `references/`). The picker auto-discovers it after `pnpm -F @html-anything/next dev` restart. The `SKILL.md` frontmatter has to set `mode` · `scenario` · `surface` · `preview` · `design_system` — copy a neighbour and edit.
- **Wire up a new coding-agent CLI** — one entry in [`next/src/lib/agents/argv.ts`](next/src/lib/agents/argv.ts) covering: detection binary, argv builder, stdin/stdout protocol, stream parser. Detection is exercised by [`next/src/app/api/agents/`](next/src/app/api/agents/) and the spawn loop by [`next/src/app/api/convert/`](next/src/app/api/convert/).
- **Add an export target** — drop a module under [`next/src/lib/export/`](next/src/lib/export/) (next to `wechat.ts` / `image.ts` / `clipboard.ts`) and add the button to the export menu. WeChat Channels · Douyin captions · Notion · Linear · Telegraph · RSS are all open.
- **Sharpen a `SKILL.md`** — strengthen the hard-constraints (CJK font stack, 8 px baseline, contrast ≥ 4.5, must-use-real-data), add a 5-dimensional self-critique, swap in a better palette. Anti-AI-slop discipline is the most underrated PR shape we accept.
- **Translations & docs** — [`README.zh-CN.md`](README.zh-CN.md) and [`CONTRIBUTING.zh-CN.md`](CONTRIBUTING.zh-CN.md) are kept in parallel with the English files; please update both.
Full walkthrough, bar-for-merging, code style, and what we **don't** accept → [`CONTRIBUTING.md`](CONTRIBUTING.md) ([简体中文](CONTRIBUTING.zh-CN.md)).
## References & lineage
Every external project this repo borrows from — what we take from each, and where it lands in the tree.
| Project | Role here |
|---|---|
| [**`nexu-io/open-design`**](https://github.com/nexu-io/open-design) | The agent-detection layer, the `DESIGN.md` design-system schema, and the `SKILL.md` skill protocol. [`next/src/lib/agents/argv.ts`](next/src/lib/agents/argv.ts) and [`next/src/lib/templates/skills/`](next/src/lib/templates/skills/) mirror this architecture verbatim. |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Daemon-and-runtime architecture: one privileged process spawns CLIs, JSON-line is the wire protocol, every CLI gets a thin per-adapter shape. |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | Anti-AI-slop discipline — Junior-Designer mode, 5-step brand-asset protocol, contrast-≥-4.5 / 8 px baseline-grid / must-use-real-data rules. Hard-coded into every [`SKILL.md`](next/src/lib/templates/skills/) frontmatter. |
| [`alchaincyf/huashu-md-html`](https://github.com/alchaincyf/huashu-md-html) | Proof that end-to-end WeChat / Zhihu paste-compatibility is solvable. Reference for [`next/src/lib/export/wechat.ts`](next/src/lib/export/wechat.ts). |
| [`mdnice/markdown-nice`](https://github.com/mdnice/markdown-nice) | `juice`-inlined-CSS pipeline → WeChat / Zhihu paste with zero re-formatting. Drives [`next/src/lib/export/wechat.ts`](next/src/lib/export/wechat.ts). |
| [`mdnice/markdown-resume`](https://github.com/mdnice/markdown-resume) | A4-formatted résumé inspiration → [`resume-modern`](next/src/lib/templates/skills/resume-modern/). |
| [`gcui-art/markdown-to-image`](https://github.com/gcui-art/markdown-to-image) | iframe → high-DPI PNG export, replicated with `modern-screenshot` in [`next/src/lib/export/image.ts`](next/src/lib/export/image.ts). |
| [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) | Magazine-ink editorial deck integrated verbatim as [`deck-guizang-editorial`](next/src/lib/templates/skills/deck-guizang-editorial/) and the Swiss variant [`deck-swiss-international`](next/src/lib/templates/skills/deck-swiss-international/). Original LICENSE + authorship preserved. |
| [**`tw93/kami`**](https://github.com/tw93/kami) | Warm-parchment monochrome editorial document system → [`doc-kami-parchment`](next/src/lib/templates/skills/doc-kami-parchment/). |
| [**`1weiho/open-slide`**](https://github.com/1weiho/open-slide) | 1920×1080 agent-native canvas convention → [`deck-open-slide-canvas`](next/src/lib/templates/skills/deck-open-slide-canvas/). |
| [`heygen-com/hyperframes`](https://github.com/heygen-com/hyperframes) | Frame-script schema for the entire `frame-*` / `vfx-*` / `mockup-*` / `social-*` family. Output hands straight off to Remotion to render `.mp4`. |
| [`remotion-dev/remotion`](https://github.com/remotion-dev/remotion) | Target renderer for Hyperframes frame scripts. |
| [`jimliu/baoyu-skills`](https://github.com/jimliu/baoyu-skills) | Practical skills collection — reference catalog for picker categorization. |
Each bundled upstream skill retains its original LICENSE and authorship inside its own `next/src/lib/templates/skills/<skill>/` folder.
## Stay in the loop
Follow [**@nexudotio**](https://x.com/nexudotio) on X for release notes, new skills, and the occasional behind-the-scenes thread on what's shipping next. The [HTML Anything Discord channel](https://discord.gg/keeVPMrueT) is where demos, template requests, export/debugging questions, and bigger community conversations happen — run by the upstream `open-design` team.
## Contributors
Thanks to everyone who has filed an issue, opened a PR, or added a skill / agent adapter / export target. Every real contribution counts, and the wall below is the easiest way to say so out loud.
<a href="https://github.com/nexu-io/html-anything/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/html-anything" alt="HTML Anything contributors" />
</a>
If you've shipped your first PR — welcome. The [`good-first-issue` / `help-wanted`](https://github.com/nexu-io/html-anything/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) labels are the entry point.
## Star History
<a href="https://star-history.com/#nexu-io/html-anything&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/html-anything&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/html-anything&type=Date" />
<img alt="HTML Anything star history" src="https://api.star-history.com/svg?repos=nexu-io/html-anything&type=Date" />
</picture>
</a>
If the curve bends up, that's the signal we look for. ★ this repo to push it.
## License
Apache-2.0 © 2026 HTML Anything contributors. See [`LICENSE`](LICENSE).
Bundled work retains its original license and authorship attribution — see the per-skill `LICENSE` / `README.md` inside each `next/src/lib/templates/skills/<skill>/` folder for what it inherits from upstream.
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`nexu-io/html-anything`
- 原始仓库:https://github.com/nexu-io/html-anything
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+404
View File
@@ -0,0 +1,404 @@
# HTML Anything
<p align="center"><sub>来自 <a href="https://github.com/nexu-io/open-design"><b>Open Design</b></a> 团队 —— <b>40k★ · 200+ 贡献者</b>,更生产级、迭代更快。html-anything 是聚焦在 agent 时代 HTML 编辑器这一刀的专项; 如果你喜欢这个味道, 同一拨人做的 <a href="https://github.com/nexu-io/open-design">Open Design</a> 是它在更大规模上的形态, 顺手也看看。</sub></p>
<p align="center"><b>项目主页:</b> <a href="https://open-design.ai/html-anything/"><b>open-design.ai/html-anything/</b></a> —— 不用 clone 也能先看看 HTML Anything 长什么样、能干啥。</p>
> **Markdown 是草稿, HTML 才是给人读的成品 —— 让本地 agent 直接写 HTML。** Agent 时代的 HTML 编辑器 —— 既然你已经不亲手改文档、全都让 Claude 改了, 那 agent 的输出就该是读者真正想看的 HTML, 而不是中间态的 markdown。本地优先、零 API Key、复用你已经登录好的 CLI session —— **9 个 coding-agent CLI** 在 `PATH` 上自动识别(Claude Code · Cursor Agent · Codex · Gemini CLI · GitHub Copilot CLI · OpenCode · Qwen Coder · Aider · IBM Bob),驱动 **75 套 skill 模板** 和 **9 类可交付场景**(杂志文章 · Keynote PPT · 简历 · 海报 · 小红书 · 推特卡 · Web 原型 · 数据报告 · Hyperframes 视频)。一键复制到公众号 / 推特 / 知乎,或者下载 `.html` / `.png`。
<p align="center">
<img src="docs/assets/banner.png" alt="HTML Anything — agent 时代的 HTML 编辑器,在你的笔记本上" width="100%" />
</p>
<p align="center">
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a>
<a href="#-自动识别本地-agent"><img alt="Agents" src="https://img.shields.io/badge/agents-9%20CLIs-black?style=flat-square" /></a>
<a href="#-skills"><img alt="Skills" src="https://img.shields.io/badge/skills-75-orange?style=flat-square" /></a>
<a href="#一键发布到平台"><img alt="Export" src="https://img.shields.io/badge/export-WeChat%20%C2%B7%20X%20%C2%B7%20Zhihu%20%C2%B7%20PNG-9b59b6?style=flat-square" /></a>
<a href="#-30-秒上手"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-30%20seconds-green?style=flat-square" /></a>
<a href="#架构"><img alt="No API key" src="https://img.shields.io/badge/no-API%20key%20required-ff6b35?style=flat-square" /></a>
</p>
<!-- 本项目站在 nexu-io/open-design 的肩膀上 — 下面这一行的社群标签都指向它,顺道带流量。 -->
<p align="center">
<a href="https://discord.gg/keeVPMrueT"><img alt="Discordhtml-anything" src="https://img.shields.io/badge/discord-html--anything-5865f2?style=flat-square&logo=discord&logoColor=white" /></a>
<a href="https://x.com/nexudotio"><img alt="X 关注 @nexudotio" src="https://img.shields.io/badge/follow-%40nexudotio-000000?style=flat-square&logo=x&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/releases/latest"><img alt="open-design 最新版本" src="https://img.shields.io/github/v/release/nexu-io/open-design?style=flat-square&label=release&color=8e44ad" /></a>
<a href="https://github.com/nexu-io/open-design/graphs/commit-activity"><img alt="open-design 月提交数" src="https://img.shields.io/github/commit-activity/m/nexu-io/open-design?style=flat-square&label=commits%2Fmonth&color=f39c12" /></a>
<a href="#-看看效果"><img alt="设计系统" src="https://img.shields.io/badge/design%20systems-9-1abc9c?style=flat-square" /></a>
<a href="https://github.com/nexu-io/open-design"><img alt="基于 open-design" src="https://img.shields.io/badge/built%20on-nexu--io%2Fopen--design-ff7043?style=flat-square&logo=github&logoColor=white" /></a>
</p>
<p align="center"><a href="README.md">English</a> · <b>简体中文</b></p>
---
## 🎨 看看效果
picker 顶部 **推荐 / Featured** 分组里默认置顶的 8 个 skill —— 对应 `SKILL.md` frontmatter 里的 `recommended:` 字段,数字越小排得越靠前。每个都附 `example.html`,repo 里双击就能看效果,不用登录、不用启服。
<table>
<tr>
<td width="50%" valign="top">
<a href="next/src/lib/templates/skills/deck-guizang-editorial/"><img src="docs/screenshots/skills/deck-guizang-editorial.png" alt="deck-guizang-editorial" /></a><br/>
<sub><b><a href="next/src/lib/templates/skills/deck-guizang-editorial/"><code>deck-guizang-editorial</code></a></b> · <i>deck</i> · <code>recommended: 1</code><br/>编辑墨水 PPT,灵感来自 <a href="https://github.com/op7418/guizang-ppt-skill"><code>op7418/guizang-ppt-skill</code></a> —— 10 套锁死版面 × 5 套调色板(墨水 / 靛蓝瓷 / 森林墨 / 牛皮纸 / 沙丘),纸感印刷质感,开起来像一本电子杂志而不是 PPT。</sub>
</td>
<td width="50%" valign="top">
<a href="next/src/lib/templates/skills/deck-swiss-international/"><img src="docs/screenshots/skills/deck-swiss-international.png" alt="deck-swiss-international" /></a><br/>
<sub><b><a href="next/src/lib/templates/skills/deck-swiss-international/"><code>deck-swiss-international</code></a></b> · <i>deck</i> · <code>recommended: 2</code><br/>瑞士国际主义 PPT —— 16 列网格 + 单一饱和 accent(Klein Blue / Lemon / Mint / Safety Orange),22 套锁死版面。冷静、理性、学院派,开会时让人觉得 "这一定是 designer 做的"。</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="next/src/lib/templates/skills/doc-kami-parchment/"><img src="docs/screenshots/skills/doc-kami-parchment.png" alt="doc-kami-parchment" /></a><br/>
<sub><b><a href="next/src/lib/templates/skills/doc-kami-parchment/"><code>doc-kami-parchment</code></a></b> · <i>doc</i> · <code>recommended: 3</code><br/>暖羊皮纸 + 墨蓝单色 editorial 文档系统,灵感来自 <a href="https://github.com/tw93/kami"><code>tw93/kami</code></a>。<code>#f5f4ed</code> 底色 + 单一衬线字体,长报告、读书笔记、one-pager、简历都能套,比纯白 markdown 高一个量级。</sub>
</td>
<td width="50%" valign="top">
<a href="next/src/lib/templates/skills/magazine-poster/"><img src="docs/screenshots/skills/magazine-poster.png" alt="magazine-poster" /></a><br/>
<sub><b><a href="next/src/lib/templates/skills/magazine-poster/"><code>magazine-poster</code></a></b> · <i>poster</i> · <code>recommended: 4</code><br/>报纸风长图海报 —— 巨字 serif headline + 双栏正文 + 6 个编号小节 + cream 纸感底色,开起来像一份印好的 Sunday paper,不是网页。</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="next/src/lib/templates/skills/video-hyperframes/"><img src="docs/screenshots/skills/video-hyperframes.png" alt="video-hyperframes" /></a><br/>
<sub><b><a href="next/src/lib/templates/skills/video-hyperframes/"><code>video-hyperframes</code></a></b> · <i>frame / video</i> · <code>recommended: 5</code><br/>Hyperframes / Remotion 兼容的视频脚本 —— 610 个连续 <code>1920×1080</code> 帧,自带 duration / transition 注释和自动播放脚本。直接交给 <a href="https://github.com/heygen-com/hyperframes"><code>heygen-com/hyperframes</code></a> 或 Remotion 渲 <code>.mp4</code>。</sub>
</td>
<td width="50%" valign="top">
<a href="next/src/lib/templates/skills/frame-glitch-title/"><img src="docs/screenshots/skills/frame-glitch-title.png" alt="frame-glitch-title" /></a><br/>
<sub><b><a href="next/src/lib/templates/skills/frame-glitch-title/"><code>frame-glitch-title</code></a></b> · <i>frame</i> · <code>recommended: 6</code><br/>故障艺术标题帧 —— cyan / magenta 像散偏移 + CRT 扫描线 + 数据腐败副标 + 角落 ASCII 噪点。Cyberpunk hero 或视频转场用。</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="next/src/lib/templates/skills/vfx-text-cursor/"><img src="docs/screenshots/skills/vfx-text-cursor.png" alt="vfx-text-cursor" /></a><br/>
<sub><b><a href="next/src/lib/templates/skills/vfx-text-cursor/"><code>vfx-text-cursor</code></a></b> · <i>vfx</i> · <code>recommended: 7</code><br/>VFX 文字光标开场 —— 光标在画布上"打字",每个字带 hot pink × cyan 像散拖尾 + 定向光斑。丢一句金句进去,就是电影级的视频片头。</sub>
</td>
<td width="50%" valign="top">
<a href="next/src/lib/templates/skills/frame-logo-outro/"><img src="docs/screenshots/skills/frame-logo-outro.png" alt="frame-logo-outro" /></a><br/>
<sub><b><a href="next/src/lib/templates/skills/frame-logo-outro/"><code>frame-logo-outro</code></a></b> · <i>frame</i> · <code>recommended: 8</code><br/>品牌 Logo 收尾帧 —— Logo 分块装配 + glow bloom + tagline 上浮 + CTA。产品发布或品牌片的片尾闭幕镜头。</sub>
</td>
</tr>
</table>
完整 skill 目录(按 mode 分类)见下方 [🎨 Skills](#-skills) 章节。
## 🤔 为什么做这个?
[Claude Code 团队成员发了一条推](https://x.com/trq212/status/2052809885763747935):他们**已经不用 markdown 写文档了,全部改成 HTML**。理由很简单 ——
| Markdown | HTML |
|---|---|
| 给作者爽 | 给读者爽 |
| 排版受模板限制 | 排版无限自由 |
| 截图发推丑得离谱 | 直接是好看的图 |
| 二次粘贴公众号要重排 | 一键格式转换 |
**HTML 是面向人的最终形态,Markdown 只是一段写作中的中间过程。**
但 "写 HTML" 在过去意味着写 CSS、调字号、对齐网格、做响应式 —— 普通用户不会,设计师懒得,作者更没耐心。我们做的事情,是让 **AI 在你按下 ⌘+Enter 之后**,把任何输入(Markdown / CSV / Excel / JSON / SQL / 草稿…)**立刻**变成一份**可交付**的 HTML,然后一键去到公众号 / 推特 / 知乎 / 任何地方。所谓 "可交付":生成完就是受众实际看到的样子,不再需要 "我先这样、待会再调"。
我们站在四个开源项目的肩膀上:
- [**`nexu-io/open-design`**](https://github.com/nexu-io/open-design) —— Agent 检测层 + 设计 system 思路 + Skills protocol;本仓库的 `next/src/lib/agents/``next/src/lib/templates/skills/*` 直接借鉴这套模型。
- [**`mdnice/markdown-nice`**](https://github.com/mdnice/markdown-nice) —— `juice` 内联 CSS、公众号 / 知乎兼容粘贴的可行性证明。
- [**`gcui-art/markdown-to-image`**](https://github.com/gcui-art/markdown-to-image) —— iframe → 高 DPI PNG 的截图导出路径。
- [**`alchaincyf/huashu-md-html`**](https://github.com/alchaincyf/huashu-md-html) —— 反 AI-slop 的话术 / 设计哲学,对应我们模板里的硬约束(中文优先字体、8px 基线网格、对比 ≥ 4.5、必须用真实数据)。
## 一览
| | 你会拿到什么 |
|---|---|
| **本地 coding-agent CLI8 个)** | Claude Code · Cursor Agent · OpenAI Codex · Gemini CLI · GitHub Copilot CLI · OpenCode · Qwen Coder · Aider —— 启动时扫描 `PATH`(含 `~/.local/bin` · `~/.bun/bin` · `/opt/homebrew/bin` · `~/.npm-global/bin` 这些 GUI 启动会漏掉的目录),顶栏一键切换。 |
| **零 API Key** | 直接复用你已经在终端登录好的 `claude login` · `cursor login` · `gemini auth` session。订阅复用 = **0 边际成本**。 |
| **Skill 模板(75 套)** | `prototype`web / SaaS landing / dashboard / 数据报告) · `deck`20 套 keynote PPT,含 Swiss International、Guizang Editorial、XHS Pastel、Hermes Cyber、Replit、Magazine Web 等) · `frame`10 套 Hyperframes 视频帧:液态背景、NYT 数据图表、贴纸流程图、像素故障字幕、电影漏光、macOS 通知、品牌片尾…) · `social`X / 小红书 / Spotify / Reddit 卡片) · `office`PM 规格书 · 工程 runbook · 财务报告 · HR onboarding · 发票 · OKR · 周报 · 会议纪要 · 看板) · `doc`Kami 暖羊皮纸 editorial · `mockup`3D 设备模型) · `vfx`(文本光标)。 |
| **9 类输出场景** | 📖 杂志文章 · 🎬 Keynote PPT · 📄 极简简历 · 🖼️ 营销海报 · 📱 小红书图文卡 · 🐦 推特分享卡 · 🛠️ Web 产品原型 · 📊 数据可视化报告 · 🎞️ Hyperframes 视频。每一类都有可挑选的具体 skill。 |
| **一键发布** | `juice` 内联 CSS → 公众号粘贴 0 调整 · `modern-screenshot` 2× PNG → 拖入推文 · `<mjx-container>``data-eeimg` 占位 → 知乎公式自动渲染 · 单文件 `.html` 下载 · 高 DPI `.png` 下载。 |
| **流式渲染** | `POST /api/convert` 走 SSEagent stdout 的 JSON-line 流式抽出 text → 客户端 append → iframe `srcdoc` 实时刷新。等待时间 = 看 AI 现场写代码的体验。 |
| **沙箱预览** | `iframe[sandbox="allow-scripts allow-same-origin"]`,用户 HTML 隔离运行,宿主页面不受污染。 |
| **格式自动识别** | 编辑器一栏,粘 Markdown / CSV / TSV / JSON / SQL / 纯文本都识别,`papaparse` + `xlsx` 在浏览器端解析,不上传服务器。 |
| **部署** | 本地 `pnpm -F @html-anything/next dev` / Vercel 一键部署 web 层(agent 永远跑在本地)。 |
| **License** | Apache-2.0 |
## Demo
<table>
<tr>
<td width="50%">
<img src="docs/screenshots/01-entry-view.png" alt="01 · 主界面" /><br/>
<sub><b>主界面</b> —— 顶栏自动识别你已装的 CLI,左侧粘内容,中间挑模板和设计 system,右侧 iframe 实时预览。同一个面板搞定杂志、PPT、海报、Web 原型、Hyperframes 帧脚本。</sub>
</td>
<td width="50%">
<img src="docs/screenshots/02-template-picker.png" alt="02 · 模板筛选" /><br/>
<sub><b>75 套模板可搜索可筛选</b> —— 按 modeprototype / deck / frame / social / office / doc)与 scenariodesign / marketing / engineering / product / personal)二维筛。每套模板都附 <code>example.html</code>,双击直接看效果。</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/03-streaming.png" alt="03 · 流式生成" /><br/>
<sub><b>SSE 流式</b> —— agent 的 stdout JSON-line 流式抽出文本,append 进 iframe <code>srcdoc</code>。你能看着它一行一行写出来,不满意可以中途打断重发,不浪费一整次生成。</sub>
</td>
<td width="50%">
<img src="docs/screenshots/04-export.png" alt="04 · 一键发布" /><br/>
<sub><b>一键发布到平台</b> —— 微信公众号(juice 内联 CSS)· 推特 / 微博 / 小红书(modern-screenshot 渲染成 2× PNG,写进 ClipboardItem)· 知乎(公式占位)· 下载 <code>.html</code> · 下载 <code>.png</code>。粘贴即用,0 二次排版。</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/05-deck-mode.png" alt="05 · Keynote PPT" /><br/>
<sub><b>Deck 模式</b> —— 20 套 PPT skill,含 Swiss International(瑞士国际主义)· Guizang Editorial(编辑墨水)· Open Slide Canvas1920×1080 agent-native)· Magazine Web · XHS Pastel · Hermes Cyber · Replit Style 等。←/→ 切页,支持演讲者备注与打印 PDF。</sub>
</td>
<td width="50%">
<img src="docs/screenshots/06-hyperframes.png" alt="06 · Hyperframes 视频帧" /><br/>
<sub><b>Hyperframes 视频帧</b> —— 10 套帧脚本(液态背景 hero · NYT 数据图表 · 贴纸流程图 · 像素故障标题 · 电影漏光 · macOS 通知 · 品牌 logo 片尾 · 文本光标 VFX · 3D 设备模型 …)符合 <a href="https://github.com/heygen-com/hyperframes">heygen-com/hyperframes</a> 规范,可直送 Remotion 渲染 mp4。</sub>
</td>
</tr>
</table>
## ⚡ 30 秒上手
```bash
git clone https://github.com/nexu-io/html-anything
cd html-anything
pnpm install
pnpm -F @html-anything/next dev
# → http://localhost:3000
```
打开浏览器 → 顶栏会自动列出你电脑上**已经登录好**的本地 code agent → 选一个模板 → 粘贴内容 → ⌘+Enter
**无需 API Key**。我们直接复用你已经在用的 Claude / Cursor / Codex 订阅,**0 边际成本**。
## Workspace
这个仓库是一个很小的 pnpm workspace
- `next/` 是完整 Next 应用(`@html-anything/next`)。
- `e2e/` 是浏览器测试包(`@html-anything/e2e`),也是 Playwright 用例的唯一真相源。
- 根目录只负责 CI、文档和 `scripts/guard.ts`;根 `package.json` 有意不代理 app 或 e2e 命令。
从仓库根目录运行 package 命令:
```bash
pnpm exec tsx scripts/guard.ts
pnpm -F @html-anything/next dev
pnpm -F @html-anything/next typecheck
pnpm -F @html-anything/next test
pnpm -F @html-anything/next build
pnpm -F @html-anything/e2e typecheck
pnpm -F @html-anything/e2e test
```
## 🧠 自动识别本地 agent
进入主页时,我们扫描你的 `PATH`(包括 `~/.local/bin``~/.bun/bin``/opt/homebrew/bin``~/.npm-global/bin` 等通过 GUI 启动会被过滤掉的目录),检测以下 CLI:
| Agent | 检测命令 | 调用方式 |
|---|---|---|
| **Claude Code** | `claude` | `claude -p --output-format stream-json` |
| **OpenAI Codex** | `codex` | `codex exec --json --sandbox workspace-write` |
| **Cursor Agent** | `cursor-agent` | `cursor-agent --print --output-format stream-json --force --trust` |
| **Gemini CLI** | `gemini` | `gemini --output-format stream-json --yolo` |
| **GitHub Copilot CLI** | `copilot` | `copilot --allow-all-tools --output-format json` |
| **OpenCode** | `opencode-cli` / `opencode` | `opencode run --format json --dangerously-skip-permissions -` |
| **Qwen Coder** | `qwen` | `qwen --yolo -` |
| **Aider** | `aider` | `aider --no-pretty --no-stream --yes-always --message-file -` |
| **IBM Bob** | `bob` | `bob --output-format stream-json --hide-intermediary-output` |
> 这一层的设计直接借鉴了 [`nexu-io/open-design`](https://github.com/nexu-io/open-design) 和 [`multica-ai/multica`](https://github.com/multica-ai/multica) 的 agent 检测策略:唯一被 spawn 子进程的进程是 server route,业务进程不直接 spawnCLI 的 stdin / stdout 用 JSON-line 协议复用,每个 CLI 一个轻 adapter,统一在 [`next/src/lib/agents/argv.ts`](next/src/lib/agents/argv.ts)。
只要你已经在终端里登录过对应的 CLI(例如 `claude login``cursor login`),HTML Anything 直接复用同一个 session**不要求你再贴一遍 API Key**。
## 🎨 Skills
**75 套 skill 在 [`next/src/lib/templates/skills/`](next/src/lib/templates/skills/) 下开箱即用。** 每个 skill 是一个文件夹,遵循 Claude Code [`SKILL.md`](https://docs.anthropic.com/en/docs/claude-code/skills) 约定 + 扩展 frontmatter`mode` · `scenario` · `surface` · `preview` · `design_system`)。
picker 用两个维度组织:
- **mode** —— `prototype`web / SaaS landing / dashboard / 数据报告 / 简历 / 文档) · `deck`20 套幻灯片 skill · `frame`10 套 Hyperframes 视频帧脚本) · `social`4 套社交平台分享卡) · `office`PM / 工程 / 财务 / HR / 行政 表格化文档)。
- **scenario** —— `design` / `marketing` / `engineering` / `product` / `finance` / `hr` / `sale` / `personal`,用于在 picker 里分组展示。
完整 skill 目录(按 mode 分类)请见 [English README](README.md#skills),结构一一对应,链接也都是同一个 repo path。新增 skill 只需要 fork 一个 skill 文件夹、改 `SKILL.md` frontmatter、重启 dev serverpicker 里就会出现;merge 标准见 [`CONTRIBUTING.zh-CN.md`](CONTRIBUTING.zh-CN.md)。
## 六个核心想法
### 1 · 我们不发 agent,你装的就够好
主界面进来时,浏览器调 `/api/agents`server 端扫一遍 `PATH`(含 GUI 启动会丢的 `~/.local/bin` · `~/.bun/bin` · `/opt/homebrew/bin` · `~/.npm-global/bin`),识别到哪些 CLI 在场,就把哪些放进顶栏。每个 CLI 对应一个 adapter(参数 + stdin 协议 + 输出解析器),在 [`next/src/lib/agents/argv.ts`](next/src/lib/agents/argv.ts) 里集中维护。整套思路直接借鉴 [`nexu-io/open-design`](https://github.com/nexu-io/open-design) 和 [`multica-ai/multica`](https://github.com/multica-ai/multica) 的 agent-detection 模型。
### 2 · Skills 是文件夹,不是插件
遵循 Claude Code [`SKILL.md`](https://docs.anthropic.com/en/docs/claude-code/skills) 约定 —— `SKILL.md` + `assets/` + `references/` + `example.html`。drop 一个文件夹到 [`next/src/lib/templates/skills/`](next/src/lib/templates/skills/),重启 dev serverpicker 里就出现。`deck-guizang-editorial` 直接 vendor 自 [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill)(保留原始 LICENSE 与署名)。
### 3 · 强制约束让 AI 不再 freestyle
每个模板都硬编码了:
- **中文优先字体栈**`Noto Sans/Serif SC` / 思源黑体;英文用 `Inter` / `Manrope`
- **8 px 基线网格**:所有间距、行高、字号都是 8 的倍数。
- **圆角 / 投影 / 不用纯黑纯白** —— 视觉上去 AI-slop 化。
- **颜色对比 ≥ 4.5**,重要交互必有 `:focus` 态。
- **必须使用用户提供的真实数据**,禁止 lorem ipsum。
灵感来自 [`alchaincyf/huashu-design`](https://github.com/alchaincyf/huashu-design) 的 "Junior-Designer mode" + 反 AI-slop checklist。约束就是 prompt 的一部分,写进每个 `SKILL.md`
### 4 · 流式渲染 = 看着 AI 现场画
`POST /api/convert` 走 SSE。Agent 的 stdout 是一行行 JSON-lineserver 抽出其中的 `text` 字段,作为 SSE event 推下去,客户端 append 进 `iframe[srcdoc]`。整个过程跟在终端里看 AI 写代码一模一样,只不过最终产物是好看的 HTML 而不是 markdown。**不满意可以打断**,不浪费一整次 token。
### 5 · 一键发布到平台 = 0 二次排版
- **微信公众号**`juice` 内联 CSS + `data-tool` 标记 → 粘进编辑器直接显示,不丢样式。
- **推特 / 微博 / 小红书**`modern-screenshot` 把 iframe 渲染成 2× PNG → `ClipboardItem` → 直接粘到推文 / 图床。
- **知乎**:同上 + `<mjx-container>``data-eeimg` LaTeX 图占位(知乎不支持 KaTeX 直渲,必须转成图)。
- **下载 `.html`** / **下载 `.png`**:自包含单文件,任意分享。
实现思路参考自 [`mdnice/markdown-nice`](https://github.com/mdnice/markdown-nice) 和 [`gcui-art/markdown-to-image`](https://github.com/gcui-art/markdown-to-image)。
### 6 · 沙箱 iframe = 安全 + 隔离
用户输出的 HTML 总是放进 `iframe[sandbox="allow-scripts allow-same-origin"]` 里。第三方脚本可以跑(Tailwind CDN / Google Fonts / 用户自定义动效),但 cookie 和 localStorage 走 iframe 自己的 origin,不污染宿主页面。打开 devtools 也只看 iframe 自己的 DOM,调试体验和写一个独立 HTML 文件一致。
## 架构
```
┌─────────────────────── Browser (Next.js 16) ──────────────────────┐
│ Editor / 上传 · 顶栏 agent picker · 模板 picker · iframe 预览 │
└─────────────┬──────────────────────────────────┬──────────────────┘
│ ⌘+Enter │
▼ ▼
┌─────────────────────┐ ┌──────────────────────┐
│ GET /api/agents │ │ POST /api/convert │
│ 扫 PATH 检测 CLI │ │ SSE 流式调用 agent │
└─────────────────────┘ └──────────┬───────────┘
│ spawn + stdin pipe
┌────────────────────────────────────┐
│ 你本地的 code agent │
│ claude / codex / cursor-agent / │
│ gemini / copilot / opencode / │
│ qwen / aider │
│ → 复用你已登录的 session │
└────────────────────────────────────┘
stdout JSON-line ──► SSE event
iframe srcdoc append(实时刷新)
⌘+C 复制 → ClipboardItem
⌘+S 下载 → .html / .png
```
| 层 | 技术栈 |
|---|---|
| Frontend | Next.js 16 App Router + Turbopack · React 19 · Tailwind v4 · zustand 全局状态 |
| Server routes | `GET /api/agents`(检测) · `POST /api/convert`SSE 流式 spawn |
| Agent transport | `child_process.spawn` · 每个 CLI 一个 stdin/stdout adapter[`next/src/lib/agents/argv.ts`](next/src/lib/agents/argv.ts) |
| 浏览器端处理 | `juice`CSS 内联) · `modern-screenshot`PNG 截图) · `xlsx` / `papaparse`CSV/Excel 解析) · `marked` + `highlight.js`markdown 兼容输入) · `dompurify`XSS 防御) |
| 预览沙箱 | `iframe[sandbox="allow-scripts allow-same-origin"]` + `srcdoc` |
| 导出 | `.html` 单文件 · `.png` 高 DPI · ClipboardItem 富文本 / image/png · 微信兼容 inline CSS |
| 部署 | 本地 `pnpm -F @html-anything/next dev` · Vercel 一键 web 层(agent 永远跑本地) |
## 一键发布到平台
| 平台 | 实现 | 粘贴效果 |
|---|---|---|
| **微信公众号** | `juice` 内联 CSS + `data-tool` 标记 | 0 调整,直接显示 |
| **知乎** | 同上 + `<mjx-container>``data-eeimg` LaTeX 图占位 | 公式自动渲染 |
| **推特 / 微博 / 小红书** | `modern-screenshot` 把 iframe 渲染成 2× PNG → ClipboardItem | 直接粘到推文 |
| **下载 `.html`** | 单文件,双击打开 | 任意分享 |
| **下载 `.png`** | 高 DPI 截图 | 任意分享 |
## 🛣️ Roadmap
- [ ] **多模板比较预览** —— 同一份内容生成 4 张候选,选最美的一张落地
- [ ] **Hyperframes → mp4** —— 一键把帧脚本送进 Remotion 渲真视频
- [ ] **共享设计 system** —— 与 [`nexu-io/open-design`](https://github.com/nexu-io/open-design) 互通 `DESIGN.md` 资产
- [ ] **模板市场** —— 社区贡献你的提示词与示例
- [ ] **浏览器扩展** —— 选中网页内容 → 一键转 HTML 模板
- [ ] **历史记录 / 版本对比 / IndexedDB 存档**
- [ ] **更多平台导出适配** —— 微信视频号 · 抖音字幕 · Notion · Linear · Telegraph
## 📍 进度
早期但能用。**识别 agent → 选 skill → SSE 流式渲染 → sandboxed iframe 预览 → 一键导出** —— 这条闭环已经跑通,8 个 CLI 全都能驱动。Skill 库和 `SKILL.md` 里的硬约束是这套东西最值钱的部分,这两块都稳定了。Picker UX、design-system 元数据、多模板对比流程还在每天迭代。**如果你本机上跑出毛病了,提 issue 时附上你用的是哪个 agent CLI + 输入内容,这种 issue 推进最快。**
| 模块 | 状态 |
|---|---|
| Agent 自动识别(8 个 CLI) | ✅ 稳定 |
| Skill 注册表 + picker(75 个 skill) | ✅ 稳定 |
| SSE 流式渲染 | ✅ 稳定 |
| Sandboxed iframe 预览 | ✅ 稳定 |
| 一键复制到公众号 / 推特 / 知乎 / `.html` / `.png` | ✅ 稳定 |
| CSV / Excel / JSON / SQL 格式自动识别 | ✅ 稳定 |
| 多模板对比(同一份生成 4 张,选 1 张落地) | 🛠 进行中 |
| Hyperframes → `.mp4` 一键交给 Remotion 渲视频 | 🛠 进行中 |
| 浏览器扩展(选中网页 → 转 HTML 模板) | ⏳ 计划中 |
| 历史记录 / 版本对比 / IndexedDB 存档 | ⏳ 计划中 |
| Skill 市场(`install <github-repo>`) | ⏳ 计划中 |
## 🤝 贡献
欢迎 issue、PR、新 skill、新 agent 适配、新平台导出、翻译。**最高杠杆的贡献往往就是一个文件夹、一份 Markdown、或者一段 PR-sized adapter** —— 面积小、影响大。按你想加的东西对号入座:
- **新 skill** —— 在 [`next/src/lib/templates/skills/`](next/src/lib/templates/skills/) 加一个文件夹,带 `SKILL.md` + `example.html`(可选 `assets/``references/`)。`pnpm -F @html-anything/next dev` 重启后 picker 自动发现。`SKILL.md` frontmatter 必须设 `mode` · `scenario` · `surface` · `preview` · `design_system` —— 抄一个邻居改就行。
- **新 coding-agent CLI 适配** —— 在 [`next/src/lib/agents/argv.ts`](next/src/lib/agents/argv.ts) 加一行,覆盖:识别用的 binary 名字、argv 拼装、stdin/stdout 协议、流式 parser。检测路径走 [`next/src/app/api/agents/`](next/src/app/api/agents/),spawn 走 [`next/src/app/api/convert/`](next/src/app/api/convert/)。
- **新平台导出** —— 在 [`next/src/lib/export/`](next/src/lib/export/) 加一个模块(放在 `wechat.ts` / `image.ts` / `clipboard.ts` 隔壁),并在导出菜单里加按钮。微信视频号 · 抖音字幕 · Notion · Linear · Telegraph · RSS 都是空位。
- **打磨某个 `SKILL.md`** —— 加强硬约束(CJK 字体栈、8 px 基线、对比度 ≥ 4.5、必须用真实数据),加一段五维自检,换一套更合适的调色板。反 AI-slop 这类 PR 是我们最看重的形态。
- **翻译与文档** —— [`README.md`](README.md) 和 [`CONTRIBUTING.md`](CONTRIBUTING.md) 都和中文版并行维护,改一边请同步另一边。
完整的贡献流程、合并门槛、代码规范、以及我们**不接受**的 PR 类型 → [`CONTRIBUTING.zh-CN.md`](CONTRIBUTING.zh-CN.md)([English](CONTRIBUTING.md))。
## 📚 References & Lineage(依赖与渊源)
这个 repo 借鉴的所有外部项目 —— 每一个我们都拿了什么、对应落到哪个目录。
| 项目 | 在这里的角色 |
|---|---|
| [**`nexu-io/open-design`**](https://github.com/nexu-io/open-design) | Agent 识别层、`DESIGN.md` 设计 system schema、`SKILL.md` 协议都来自这里。[`next/src/lib/agents/argv.ts`](next/src/lib/agents/argv.ts) 和 [`next/src/lib/templates/skills/`](next/src/lib/templates/skills/) 直接镜像了它的架构。 |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Daemon-and-runtime 架构:一个特权进程 spawn 各家 CLI、JSON-line 做线协议、每个 CLI 一个薄 adapter。 |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | 反 AI-slop 设计哲学 —— Junior-Designer 工作流、五步品牌资产协议、对比度 ≥ 4.5 / 8 px 基线 / 必须用真实数据。这些硬约束直接写进了每一个 [`SKILL.md`](next/src/lib/templates/skills/) frontmatter。 |
| [`alchaincyf/huashu-md-html`](https://github.com/alchaincyf/huashu-md-html) | 公众号 / 知乎复制端到端兼容性的可行性证明。[`next/src/lib/export/wechat.ts`](next/src/lib/export/wechat.ts) 的参考实现。 |
| [`mdnice/markdown-nice`](https://github.com/mdnice/markdown-nice) | `juice` 内联 CSS 链路 → 公众号 / 知乎粘贴零调整。驱动 [`next/src/lib/export/wechat.ts`](next/src/lib/export/wechat.ts)。 |
| [`mdnice/markdown-resume`](https://github.com/mdnice/markdown-resume) | A4 简历版式的灵感来源 → [`resume-modern`](next/src/lib/templates/skills/resume-modern/)。 |
| [`gcui-art/markdown-to-image`](https://github.com/gcui-art/markdown-to-image) | iframe → 高 DPI PNG 截图路径,用 `modern-screenshot` 复刻在 [`next/src/lib/export/image.ts`](next/src/lib/export/image.ts)。 |
| [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) | 编辑墨水 PPT 原封不动接入 [`deck-guizang-editorial`](next/src/lib/templates/skills/deck-guizang-editorial/) 和瑞士国际主义变体 [`deck-swiss-international`](next/src/lib/templates/skills/deck-swiss-international/)。原 LICENSE + 作者署名保留。 |
| [**`tw93/kami`**](https://github.com/tw93/kami) | 暖羊皮纸 + 墨蓝单色 editorial 文档系统 → [`doc-kami-parchment`](next/src/lib/templates/skills/doc-kami-parchment/)。 |
| [**`1weiho/open-slide`**](https://github.com/1weiho/open-slide) | 1920×1080 agent-native canvas 规范 → [`deck-open-slide-canvas`](next/src/lib/templates/skills/deck-open-slide-canvas/)。 |
| [`heygen-com/hyperframes`](https://github.com/heygen-com/hyperframes) | 整个 `frame-*` / `vfx-*` / `mockup-*` / `social-*` 家族遵循的帧脚本 schema。输出可以直接交给 Remotion 渲 `.mp4`。 |
| [`remotion-dev/remotion`](https://github.com/remotion-dev/remotion) | Hyperframes 帧脚本的目标渲染器。 |
| [`jimliu/baoyu-skills`](https://github.com/jimliu/baoyu-skills) | 实用 skills 集合 —— picker 分类参考目录。 |
每一个 vendor 进来的 upstream skill 都在 `next/src/lib/templates/skills/<skill>/` 里保留了原始 LICENSE 和作者署名。
## 📣 关注我们
X 上关注 [**@nexudotio**](https://x.com/nexudotio),看版本发布、新 skill、新 design system,以及偶尔的 behind-the-scenes 线程。[HTML Anything Discord 频道](https://discord.gg/keeVPMrueT) 用来展示 demo、提模板需求、排查导出/agent 问题,也承接更长篇的社区讨论 —— 由上游 `open-design` 团队维护。
## 👥 贡献者
感谢每一个提 issue、开 PR、加 skill / agent 适配 / 导出适配的人。下面这面墙是我们能想到最直观的感谢方式。
<a href="https://github.com/nexu-io/html-anything/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/html-anything" alt="HTML Anything 贡献者" />
</a>
如果你刚刚开第一个 PR —— 欢迎。[`good-first-issue` / `help-wanted`](https://github.com/nexu-io/html-anything/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 标签是最好的切入口。
## ⭐ Star History
<a href="https://star-history.com/#nexu-io/html-anything&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/html-anything&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/html-anything&type=Date" />
<img alt="HTML Anything star history" src="https://api.star-history.com/svg?repos=nexu-io/html-anything&type=Date" />
</picture>
</a>
曲线往上翘,就是我们要找的信号。点个 ★ 推一把。
## 📄 License
Apache-2.0 © 2026 HTML Anything 贡献者。详见 [`LICENSE`](LICENSE)。
vendor 进来的第三方作品保留原始 LICENSE 与署名 —— 每个 `next/src/lib/templates/skills/<skill>/` 文件夹里若存在 `LICENSE` / `README.md`,以它为准。
+2
View File
@@ -0,0 +1,2 @@
dist/
node_modules/
+288
View File
@@ -0,0 +1,288 @@
# html-anything CLI
从命令行直接将 Markdown(或纯文本/CSV/JSON)转换为精美排版的 HTML 文件,无需打开网页界面。
## 安装
```bash
# 1. 进入项目目录,安装依赖
cd html-anything
pnpm install
# 2. 构建 CLI
pnpm -F @html-anything/cli build
```
### 全局安装(可选)
构建完成后,可以创建全局链接:
```bash
# 在 cli 目录下创建全局链接
cd cli
npm link
# 之后可以在任意目录使用
html-anything --help
```
或者将以下别名添加到 `~/.zshrc``~/.bashrc`
```bash
alias html-anything="node /path/to/html-anything/cli/dist/run.js"
```
## 快速开始
### 1. 设置默认模板(推荐)
```bash
# 查看所有可用模板
html-anything templates
# 设置一个默认模板,之后 convert 时无需每次都指定
html-anything config set-default-template doc-kami-parchment
```
### 2. 转换 Markdown 文件
```bash
# 自动匹配模板(推荐:无需手动选模板)
html-anything auto article.md
# 仅查看匹配结果,不执行转换
html-anything auto article.md --show-match-only
# 使用默认模板转换(自动保存为 article.html
html-anything convert article.md
# 批量转换多个文件
html-anything convert file1.md file2.md file3.md -d ./dist
# 保存到指定文件
html-anything convert article.md -o output.html
# 指定自动保存目录
html-anything convert article.md -d ./dist
# 指定模板
html-anything convert article.md -t resume-modern
# 指定 AI agent
html-anything convert article.md -a claude --model sonnet
# 从标准输入读取(输出到 stdout)
cat article.md | html-anything convert -o page.html
```
### 3. 查看生成结果
```bash
# 用浏览器打开生成的 HTML
open output.html
```
## 命令详解
### `convert` / `auto` — 转换内容
两个命令共享以下通用参数:
| 参数 | 简写 | 说明 | 默认值 |
|------|------|------|--------|
| `input` | — | 输入文件路径,省略则从 stdin 读取 | stdin |
| `--agent <id>` | `-a` | AI agent ID | 自动检测第一个可用 agent |
| `--output <path>` | `-o` | 输出文件路径 | 自动保存为 `<输入文件名>.html`stdin 输入时输出到 stdout |
| `--output-dir <dir>` | `-d` | 自动保存目录 | 当前目录 |
| `--model <id>` | — | 使用的模型 | agent 默认模型 |
| `--format <type>` | — | 输入格式:markdown, text, csv, json | markdown |
#### `convert` — 指定模板转换
```bash
html-anything convert [input] [options]
```
用户明确指定模板 ID 来转换内容。
| 参数 | 简写 | 说明 | 默认值 |
|------|------|------|--------|
| `--template <id>` | `-t` | 模板 ID | 配置中的 default-template |
#### `auto` — 自动匹配模板并转换
```bash
html-anything auto [input] [options]
```
无需手动选择模板,CLI 自动分析内容主题,从 75 个模板中匹配最合适的模板,然后执行转换。
| 参数 | 简写 | 说明 | 默认值 |
|------|------|------|--------|
| `--force-ai` | — | 跳过关键词匹配,强制使用 AI summary | — |
| `--show-match-only` | — | 仅显示匹配结果,不执行转换 | — |
### `templates` — 列出模板
```bash
html-anything templates
```
列出所有 75 个可用模板,按类别分组显示。已设为默认的模板会标记 `(default)`
### `agents` — 列出 Agent
```bash
html-anything agents
```
列出系统中已安装的 AI agent CLI。`✓` 表示可用,`✗` 表示未安装。
### `config` — 配置管理
```bash
html-anything config # 查看当前配置
html-anything config set-default-template <id> # 设置默认模板
html-anything config set-default-agent <id> # 设置默认 agent
html-anything config set-model <id> # 设置默认模型
html-anything config reset # 重置所有配置
```
配置文件位于 `~/.config/html-anything/config.json`
## 支持的 AI Agent
html-anything 本身不做 AI 生成,它会自动检测并调用你系统里已安装的 AI CLI 工具(任意一个即可)来完成转换。支持的 AI CLI:
| Agent | 安装方式 |
|-------|----------|
| Claude Code | `npm install -g @anthropic-ai/claude-code` |
| OpenAI Codex | `npm install -g @openai/codex` |
| Cursor Agent | 安装 Cursor 编辑器后可用 |
| Gemini CLI | `npm install -g @google/gemini-cli` |
| GitHub Copilot CLI | `npm install -g @github/copilot-cli` |
| OpenCode | `npm install -g @open/open-cli` |
| Qwen Coder | `npm install -g @alibaba/qwen-coder` |
| CodeWhale | `npm install -g codewhale` |
| DeepSeek TUI | `npm install -g deepseek-tui` |
| Aider | `pip install aider-chat` |
| OpenClaw | 参考官方文档安装 |
## 常用模板推荐
| 模板 ID | 名称 | 适用场景 |
|---------|------|----------|
| `doc-kami-parchment` | Kami 羊皮纸文档 | 长文、报告、one-pager |
| `resume-modern` | 极简简历 | A4 单页简历 |
| `deck-swiss-international` | 瑞士国际主义 Deck | 演示文稿 |
| `deck-guizang-editorial` | 贵赞编辑墨水 Deck | 杂志风 PPT |
| `magazine-poster` | 杂志风海报 | 海报、宣传单页 |
| `blog-post` | 博客长文 | 技术博客 |
| `data-report` | 数据可视化报告 | 数据分析报告 |
| `card-xiaohongshu` | 小红书图文卡片 | 社交媒体图文 |
| `prototype-web` | Web 产品原型 | 产品原型 |
| `saas-landing` | SaaS Landing | 产品落地页 |
## 完整使用示例
```bash
# 1. 首次使用:查看有哪些模板
html-anything templates
# 2. 设置你最喜欢的模板为默认
html-anything config set-default-template doc-kami-parchment
# 3. 写一篇 Markdown 文章
cat > my-article.md << 'EOF'
# 我的项目总结
## 背景
这是一个关于...
## 成果
- 完成了 A 功能
- 优化了 B 模块
## 下一步
我们计划在 Q3 完成...
EOF
# 4. 一键自动匹配模板并转换(推荐)
html-anything auto my-article.md
# 5. 在浏览器中查看结果
open my-article.html
# 6. 如果只想看匹配结果
html-anything auto my-article.md --show-match-only
# 7. 如果想换个风格
html-anything convert my-article.md -t blog-post -o my-article-v2.html
# 8. 保存到指定目录
html-anything convert my-article.md -d ./output
```
## 开发
```bash
# 开发模式(无需构建,直接运行 TypeScript)
pnpm -F @html-anything/cli dev -- templates
# 类型检查
pnpm -F @html-anything/cli typecheck
# 构建
pnpm -F @html-anything/cli build
```
## 工作原理
### `convert` 命令流程
1. **模板加载**:从 `next/src/lib/templates/skills/` 加载 75 个 SKILL 模板,每个模板包含视觉风格定义和排版规则
2. **Prompt 拼接**:将全局设计指令 + 模板专属规则 + 用户内容拼接成一个完整的 AI prompt
3. **Agent 调用**:调用本地安装的 AI agent CLI(如 Claude Code),让 AI 根据 prompt 生成 HTML
4. **HTML 提取**:从 agent 的流式输出中提取完整的 HTML 文档
5. **输出**:将 HTML 写入文件或打印到 stdout
### `auto` 命令流程
```
用户内容
┌─────────────────────────┐
│ 第一层:强信号关键词匹配 │ ← 零 token,毫秒级
│ 命中 → 直接使用匹配模板 │
│ (简历→resume-modern 等) │
└──────────┬──────────────┘
│ 未命中
┌─────────────────────────┐
│ 第二层:规则打分匹配 │ ← 零 token,毫秒级
│ 内容 × 全部模板 metadata │
│ (tags + name + desc + │
│ scenario keywords) │
│ 置信度 ≥ 阈值 → 使用 │
└──────────┬──────────────┘
│ 置信度不足
┌─────────────────────────┐
│ 第三层:AI Summary 兜底 │ ← 仅在规则失配时
│ 提取内容前 800 字 │
│ → AI 判断主题类型 │
│ → 再次规则匹配 │
└──────────┬──────────────┘
执行转换
```
**匹配策略说明**
- **强信号**(~80 条规则):覆盖简历、定价、OKR、PRD、周报等高频场景,命中即定
- **规则打分**:遍历所有模板的 tags、名称、描述、场景关键词,累加得分
- **AI 兜底**:内容 ≥ 60 字且前两层均低置信度时,调用 AI 做一句话主题摘要,仅消耗极少量 token
- **最终兜底**:若所有层均失败,回退到 `deck-swiss-international` 通用模板
整个过程完全本地运行,不依赖任何外部 API key,使用你已有的 agent 订阅。转换过程中会显示动画进度指示器,展示已接收的文本块数和耗时。
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@html-anything/cli",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"bin": {
"html-anything": "./dist/run.js"
},
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^20",
"tsx": "^4.22.1",
"typescript": "^5",
"vitest": "^4.1.6"
}
}
+329
View File
@@ -0,0 +1,329 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const { existsSyncMock } = vi.hoisted(() => ({
existsSyncMock: vi.fn((_path?: string) => false),
}));
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return { ...actual, existsSync: existsSyncMock };
});
import { detectAgents } from "../agents-detect.js";
function findAgent(
agents: ReturnType<typeof detectAgents>,
id: string,
) {
const agent = agents.find((a) => a.id === id);
if (!agent) throw new Error(`Agent with id "${id}" not found`);
return agent;
}
beforeEach(() => {
existsSyncMock.mockReset();
existsSyncMock.mockReturnValue(false);
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe("detectAgents", () => {
describe("*_BIN env override with absolute path that exists", () => {
it("finds claude via absolute CLAUDE_BIN path", () => {
vi.stubEnv("CLAUDE_BIN", "/usr/local/bin/claude");
existsSyncMock.mockImplementation(
(p) => p === "/usr/local/bin/claude",
);
const agents = detectAgents();
const claude = findAgent(agents, "claude");
expect(claude.available).toBe(true);
expect(claude.path).toBe("/usr/local/bin/claude");
expect(claude.resolvedBin).toBe("claude");
expect(claude.protocol).toBe("stdin");
expect(claude.unsupported).toBeUndefined();
});
});
describe("*_BIN with command name resolved on PATH", () => {
it("finds gemini via GEMINI_BIN command name on PATH", () => {
vi.stubEnv("GEMINI_BIN", "fake-gemini");
existsSyncMock.mockImplementation((p) => {
if (p === "/usr/local/bin/fake-gemini") return true;
return false;
});
const agents = detectAgents();
const gemini = findAgent(agents, "gemini");
expect(gemini.available).toBe(true);
expect(gemini.resolvedBin).toBe("fake-gemini");
expect(gemini.path).toBe("/usr/local/bin/fake-gemini");
});
});
describe("*_BIN pointing to non-existent path", () => {
it("returns unavailable when CLAUDE_BIN path does not exist", () => {
vi.stubEnv("CLAUDE_BIN", "/nonexistent/claude");
existsSyncMock.mockReturnValue(false);
const agents = detectAgents();
const claude = findAgent(agents, "claude");
expect(claude.available).toBe(false);
expect(claude.path).toBeUndefined();
expect(claude.resolvedBin).toBeUndefined();
});
});
describe("no env override, binary found on PATH", () => {
it("detects claude when binary is on PATH without env override", () => {
existsSyncMock.mockImplementation((p) => {
if (p === "/usr/local/bin/claude") return true;
return false;
});
const agents = detectAgents();
const claude = findAgent(agents, "claude");
expect(claude.available).toBe(true);
expect(claude.path).toBe("/usr/local/bin/claude");
expect(claude.resolvedBin).toBe("claude");
});
it("detects aider which has no envOverride via bin on PATH", () => {
existsSyncMock.mockImplementation((p) => {
if (p === "/usr/local/bin/aider") return true;
return false;
});
const agents = detectAgents();
const aider = findAgent(agents, "aider");
expect(aider.available).toBe(true);
expect(aider.path).toBe("/usr/local/bin/aider");
expect(aider.resolvedBin).toBe("aider");
});
it("detects opencode via fallbackBins when primary bin not found", () => {
existsSyncMock.mockImplementation((p) => {
if (p === "/usr/local/bin/opencode") return true;
return false;
});
const agents = detectAgents();
const opencode = findAgent(agents, "opencode");
expect(opencode.available).toBe(true);
expect(opencode.path).toBe("/usr/local/bin/opencode");
expect(opencode.resolvedBin).toBe("opencode");
});
it("returns unavailable when no binary is on PATH and no env override", () => {
existsSyncMock.mockReturnValue(false);
const agents = detectAgents();
const claude = findAgent(agents, "claude");
expect(claude.available).toBe(false);
});
});
describe("unsupported protocol agents", () => {
it("marks hermes with acp protocol as unsupported even when found", () => {
existsSyncMock.mockImplementation((p) => {
if (p === "/usr/local/bin/hermes") return true;
return false;
});
const agents = detectAgents();
const hermes = findAgent(agents, "hermes");
expect(hermes.available).toBe(true);
expect(hermes.protocol).toBe("acp");
expect(hermes.unsupported).toBe(true);
});
it("marks pi with pi-rpc protocol as unsupported even when found", () => {
existsSyncMock.mockImplementation((p) => {
if (p === "/usr/local/bin/pi") return true;
return false;
});
const agents = detectAgents();
const pi = findAgent(agents, "pi");
expect(pi.available).toBe(true);
expect(pi.protocol).toBe("pi-rpc");
expect(pi.unsupported).toBe(true);
});
it("marks unsupported as undefined for stdin protocol agents", () => {
existsSyncMock.mockImplementation((p) => {
if (p === "/usr/local/bin/claude") return true;
return false;
});
const agents = detectAgents();
const claude = findAgent(agents, "claude");
expect(claude.protocol).toBe("stdin");
expect(claude.unsupported).toBeUndefined();
});
it("marks kimi with acp protocol as unsupported even when not found", () => {
existsSyncMock.mockReturnValue(false);
const agents = detectAgents();
const kimi = findAgent(agents, "kimi");
expect(kimi.available).toBe(false);
expect(kimi.protocol).toBe("acp");
expect(kimi.unsupported).toBe(true);
});
});
describe("agent protocol assignment", () => {
it("defaults protocol to stdin for agents without explicit protocol", () => {
existsSyncMock.mockImplementation((p) => {
if (p === "/usr/local/bin/codex") return true;
return false;
});
const agents = detectAgents();
const codex = findAgent(agents, "codex");
expect(codex.protocol).toBe("stdin");
});
it("preserves explicit protocol from agent definition (argv)", () => {
existsSyncMock.mockImplementation((p) => {
if (p === "/usr/local/bin/codewhale") return true;
return false;
});
const agents = detectAgents();
const codewhale = findAgent(agents, "codewhale");
expect(codewhale.protocol).toBe("argv");
});
it("preserves explicit protocol from agent definition (argv) for deepseek-tui", () => {
existsSyncMock.mockImplementation((p) => {
if (p === "/usr/local/bin/deepseek-tui") return true;
return false;
});
const agents = detectAgents();
const deepseek = findAgent(agents, "deepseek-tui");
expect(deepseek.protocol).toBe("argv");
});
it("preserves explicit protocol from agent definition (argv-message)", () => {
existsSyncMock.mockImplementation((p) => {
if (p === "/usr/local/bin/openclaw") return true;
return false;
});
const agents = detectAgents();
const openclaw = findAgent(agents, "openclaw");
expect(openclaw.protocol).toBe("argv-message");
});
});
describe("env override precedence", () => {
it("prefers env override over PATH binary", () => {
vi.stubEnv("CLAUDE_BIN", "/custom/path/claude");
existsSyncMock.mockImplementation((p) => {
if (p === "/custom/path/claude") return true;
if (p === "/usr/local/bin/claude") return true;
return false;
});
const agents = detectAgents();
const claude = findAgent(agents, "claude");
expect(claude.available).toBe(true);
expect(claude.path).toBe("/custom/path/claude");
expect(claude.resolvedBin).toBe("claude");
});
it("falls back to PATH when env override is a command name that exists on PATH", () => {
vi.stubEnv("CLAUDE_BIN", "my-custom-claude");
existsSyncMock.mockImplementation((p) => {
if (p === "/usr/local/bin/my-custom-claude") return true;
return false;
});
const agents = detectAgents();
const claude = findAgent(agents, "claude");
expect(claude.available).toBe(true);
expect(claude.path).toBe("/usr/local/bin/my-custom-claude");
expect(claude.resolvedBin).toBe("my-custom-claude");
});
});
describe("returned agent shape", () => {
it("returns all agents from AGENTS array", () => {
existsSyncMock.mockReturnValue(false);
const agents = detectAgents();
expect(agents.length).toBeGreaterThanOrEqual(10);
});
it("each agent includes id, label, vendor, available, protocol, and models", () => {
existsSyncMock.mockReturnValue(false);
const agents = detectAgents();
for (const agent of agents) {
expect(agent).toHaveProperty("id");
expect(typeof agent.id).toBe("string");
expect(agent).toHaveProperty("label");
expect(typeof agent.label).toBe("string");
expect(agent).toHaveProperty("vendor");
expect(typeof agent.vendor).toBe("string");
expect(agent).toHaveProperty("available");
expect(typeof agent.available).toBe("boolean");
expect(agent).toHaveProperty("protocol");
expect(agent).toHaveProperty("models");
expect(Array.isArray(agent.models)).toBe(true);
}
});
it("each available agent has path and resolvedBin", () => {
existsSyncMock.mockImplementation((p) => {
if (p === "/usr/local/bin/claude") return true;
if (p === "/usr/local/bin/aider") return true;
return false;
});
const agents = detectAgents();
const availableAgents = agents.filter((a) => a.available);
for (const agent of availableAgents) {
expect(agent).toHaveProperty("path");
expect(typeof agent.path).toBe("string");
expect(agent).toHaveProperty("resolvedBin");
expect(typeof agent.resolvedBin).toBe("string");
}
});
it("models array is non-empty for each agent", () => {
existsSyncMock.mockReturnValue(false);
const agents = detectAgents();
for (const agent of agents) {
expect(agent.models.length).toBeGreaterThan(0);
}
});
});
});
+530
View File
@@ -0,0 +1,530 @@
import { vi, describe, it, expect, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { PassThrough, Writable } from "node:stream";
const { mockSpawn, existsSyncDelegate } = vi.hoisted(() => ({
mockSpawn: vi.fn(),
existsSyncDelegate: vi.fn((p: string) => p === "/bin/sh"),
}));
vi.mock("node:child_process", () => ({
spawn: mockSpawn,
}));
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return { ...actual, existsSync: existsSyncDelegate };
});
import { invokeAgent, type InvokeEvent } from "../agents-invoke.js";
function makeFakeChild() {
const stdout = new PassThrough();
const stderr = new PassThrough();
const stdin = new Writable({
write(_chunk: unknown, _enc: unknown, cb: () => void) {
cb();
},
});
const child = Object.assign(new EventEmitter(), {
stdin,
stdout,
stderr,
pid: 99999,
});
return { child, stdout, stderr, stdin };
}
async function collectStream(
stream: ReadableStream<InvokeEvent>,
): Promise<InvokeEvent[]> {
const events: InvokeEvent[] = [];
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) events.push(value);
}
return events;
}
async function driveInvoke(
opts: Parameters<typeof invokeAgent>[0],
stdoutContent: string | null,
exitCode: number | null = 0,
): Promise<InvokeEvent[]> {
const { child, stdout } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const stream = invokeAgent(opts);
await new Promise((r) => setTimeout(r, 0));
const eventsPromise = collectStream(stream);
if (stdoutContent != null) {
stdout.write(stdoutContent);
}
stdout.end();
await new Promise((r) => setImmediate(r));
child.emit("close", exitCode);
return eventsPromise;
}
const BIN_OVERRIDE = "/bin/sh";
describe("invokeAgent", () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe("error cases", () => {
it("returns error stream for unknown agent", async () => {
const stream = invokeAgent({
agent: "nonexistent",
prompt: "test",
binOverride: BIN_OVERRIDE,
});
const events = await collectStream(stream);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
type: "error",
message: expect.stringContaining("unknown agent"),
});
});
it("returns error stream when binOverride points to missing file", async () => {
const stream = invokeAgent({
agent: "claude",
prompt: "test",
binOverride: "/nonexistent/path/to/bin",
});
const events = await collectStream(stream);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
type: "error",
message: expect.stringContaining("does not exist"),
});
});
it("returns error stream for unsupported (acp) agent protocol", async () => {
const stream = invokeAgent({
agent: "hermes",
prompt: "test",
binOverride: BIN_OVERRIDE,
});
const events = await collectStream(stream);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
type: "error",
message: expect.stringContaining("not yet wired up"),
});
});
});
describe("relative binOverride resolution", () => {
it("resolves ./mock-agent via path.resolve + existsSync", async () => {
existsSyncDelegate.mockImplementation((p: string) => {
if (p.endsWith("/mock-agent")) return true;
return p === "/bin/sh";
});
const html = "<html><body>ok</body></html>";
const events = await driveInvoke(
{ agent: "deepseek-tui", prompt: "test", binOverride: "./mock-agent" },
html,
0,
);
const start = events.find((e) => e.type === "start");
expect(start).toBeDefined();
expect(start).toMatchObject({
type: "start",
bin: expect.stringContaining("/mock-agent"),
});
});
it("resolves ../bin/claude wrapper relative path", async () => {
existsSyncDelegate.mockImplementation((p: string) => {
if (p.endsWith("/bin/claude")) return true;
return p === "/bin/sh";
});
const html = "<html><body>ok</body></html>";
const events = await driveInvoke(
{ agent: "claude", prompt: "test", binOverride: "../bin/claude" },
html,
0,
);
const start = events.find((e) => e.type === "start");
expect(start).toBeDefined();
expect(start).toMatchObject({
type: "start",
bin: expect.stringContaining("/bin/claude"),
});
});
});
describe("close-path: codewhale, deepseek-tui and aider agents", () => {
it("deepseek-tui: enqueues remaining stdoutBuf as single delta on close (HTML, no trailing newline)", async () => {
const html = "<html><body>hello</body></html>";
const events = await driveInvoke(
{ agent: "deepseek-tui", prompt: "make a page", binOverride: BIN_OVERRIDE },
html,
0,
);
const deltas = events.filter((e) => e.type === "delta");
const done = events.filter((e) => e.type === "done");
expect(deltas).toHaveLength(1);
expect(deltas[0]).toMatchObject({ type: "delta", text: html });
expect(done).toHaveLength(1);
expect(done[0]).toMatchObject({ type: "done", code: 0 });
const start = events.filter((e) => e.type === "start");
expect(start).toHaveLength(1);
expect(events).toHaveLength(3);
});
it("deepseek-tui: produces only ONE delta for partial line after complete lines", async () => {
const content = "line1\nline2";
const events = await driveInvoke(
{ agent: "deepseek-tui", prompt: "make a page", binOverride: BIN_OVERRIDE },
content,
0,
);
const deltas = events.filter((e) => e.type === "delta");
const done = events.filter((e) => e.type === "done");
expect(deltas).toHaveLength(2);
expect(deltas[0]).toMatchObject({ type: "delta", text: "line1\n" });
expect(deltas[1]).toMatchObject({ type: "delta", text: "line2" });
expect(done).toHaveLength(1);
expect(done[0]).toMatchObject({ type: "done", code: 0 });
});
it("deepseek-tui: no residual on close when line ends with newline (no double-enqueue)", async () => {
const content = "hello\n";
const events = await driveInvoke(
{ agent: "deepseek-tui", prompt: "make a page", binOverride: BIN_OVERRIDE },
content,
0,
);
const deltas = events.filter((e) => e.type === "delta");
const done = events.filter((e) => e.type === "done");
expect(deltas).toHaveLength(1);
expect(deltas[0]).toMatchObject({ type: "delta", text: "hello\n" });
expect(done).toHaveLength(1);
expect(done[0]).toMatchObject({ type: "done", code: 0 });
});
it("codewhale: enqueues remaining stdoutBuf as single delta on close (HTML, no trailing newline)", async () => {
const html = "<html><body>hello from codewhale</body></html>";
const events = await driveInvoke(
{ agent: "codewhale", prompt: "make a page", binOverride: BIN_OVERRIDE },
html,
0,
);
const deltas = events.filter((e) => e.type === "delta");
const done = events.filter((e) => e.type === "done");
expect(deltas).toHaveLength(1);
expect(deltas[0]).toMatchObject({ type: "delta", text: html });
expect(done).toHaveLength(1);
expect(done[0]).toMatchObject({ type: "done", code: 0 });
});
it("aider: enqueues remaining stdoutBuf as single delta on close (HTML, no trailing newline)", async () => {
const html = "<html><body>hello from aider</body></html>";
const events = await driveInvoke(
{ agent: "aider", prompt: "make a page", binOverride: BIN_OVERRIDE },
html,
0,
);
const deltas = events.filter((e) => e.type === "delta");
const done = events.filter((e) => e.type === "done");
expect(deltas).toHaveLength(1);
expect(deltas[0]).toMatchObject({ type: "delta", text: html });
expect(done).toHaveLength(1);
expect(done[0]).toMatchObject({ type: "done", code: 0 });
expect(events).toHaveLength(3);
});
it("aider: does NOT double-enqueue partial line after complete lines", async () => {
const content = "aider-line\npartial";
const events = await driveInvoke(
{ agent: "aider", prompt: "test", binOverride: BIN_OVERRIDE },
content,
0,
);
const deltas = events.filter((e) => e.type === "delta");
expect(deltas).toHaveLength(2);
expect(deltas[0]).toMatchObject({ type: "delta", text: "aider-line\n" });
expect(deltas[1]).toMatchObject({ type: "delta", text: "partial" });
});
});
describe("close-path: non-deepseek-tui/aider agents", () => {
it("codex: parses remaining stdoutBuf on close (valid JSON delta)", async () => {
const json = '{"type":"item.delta","text":"parsed on close"}';
const events = await driveInvoke(
{ agent: "codex", prompt: "test", binOverride: BIN_OVERRIDE },
json,
0,
);
const deltas = events.filter((e) => e.type === "delta");
const done = events.filter((e) => e.type === "done");
expect(deltas).toHaveLength(1);
expect(deltas[0]).toMatchObject({ type: "delta", text: "parsed on close" });
expect(done).toHaveLength(1);
expect(done[0]).toMatchObject({ type: "done", code: 0 });
});
it("codex: HTML content on close is not JSON-parsed (skipped)", async () => {
const html = "<html><body>not json</body></html>";
const events = await driveInvoke(
{ agent: "codex", prompt: "test", binOverride: BIN_OVERRIDE },
html,
0,
);
const deltas = events.filter((e) => e.type === "delta");
const done = events.filter((e) => e.type === "done");
expect(deltas).toHaveLength(0);
expect(done).toHaveLength(1);
expect(done[0]).toMatchObject({ type: "done", code: 0 });
});
it("claude: parses remaining stdoutBuf on close (result usage meta)", async () => {
const json = '{"type":"result","usage":{"input_tokens":10,"output_tokens":20}}';
const events = await driveInvoke(
{ agent: "claude", prompt: "test", binOverride: BIN_OVERRIDE },
json,
0,
);
const metas = events.filter((e) => e.type === "meta");
const done = events.filter((e) => e.type === "done");
const usageMeta = metas.find(
(e) => e.type === "meta" && e.key === "usage",
);
expect(usageMeta).toBeDefined();
expect(done).toHaveLength(1);
});
});
describe("exit code propagation", () => {
it("done event reflects exit code 0", async () => {
const events = await driveInvoke(
{ agent: "deepseek-tui", prompt: "test", binOverride: BIN_OVERRIDE },
"ok",
0,
);
const done = events.find((e) => e.type === "done");
expect(done).toMatchObject({ type: "done", code: 0 });
});
it("done event reflects exit code 1", async () => {
const events = await driveInvoke(
{ agent: "deepseek-tui", prompt: "test", binOverride: BIN_OVERRIDE },
"fail",
1,
);
const done = events.find((e) => e.type === "done");
expect(done).toMatchObject({ type: "done", code: 1 });
});
it("done event with null code (signal exit)", async () => {
const events = await driveInvoke(
{ agent: "deepseek-tui", prompt: "test", binOverride: BIN_OVERRIDE },
"killed",
null,
);
const done = events.find((e) => e.type === "done");
expect(done).toMatchObject({ type: "done", code: null });
});
});
describe("child process error", () => {
it("produces error event when child emits error", async () => {
const { child, stdout } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const stream = invokeAgent({
agent: "deepseek-tui",
prompt: "test",
binOverride: BIN_OVERRIDE,
});
await new Promise((r) => setTimeout(r, 0));
const eventsPromise = collectStream(stream);
stdout.end();
await new Promise((r) => setImmediate(r));
child.emit("error", new Error("spawn ENOENT"));
const events = await eventsPromise;
const errors = events.filter((e) => e.type === "error");
expect(errors).toHaveLength(1);
expect(errors[0]).toMatchObject({
type: "error",
message: "spawn ENOENT",
});
});
});
describe("stderr propagation", () => {
it("produces stderr events for stderr output", async () => {
const { child, stdout, stderr } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const stream = invokeAgent({
agent: "deepseek-tui",
prompt: "test",
binOverride: BIN_OVERRIDE,
});
await new Promise((r) => setTimeout(r, 0));
const eventsPromise = collectStream(stream);
stderr.write("warning: deprecated\n");
stdout.end();
await new Promise((r) => setImmediate(r));
child.emit("close", 0);
const events = await eventsPromise;
const stderrEvents = events.filter((e) => e.type === "stderr");
expect(stderrEvents.length).toBeGreaterThanOrEqual(1);
expect(stderrEvents[0]).toMatchObject({
type: "stderr",
text: "warning: deprecated\n",
});
});
});
describe("start event", () => {
it("includes bin, argv, and promptBytes", async () => {
const events = await driveInvoke(
{ agent: "deepseek-tui", prompt: "hello world", binOverride: BIN_OVERRIDE },
null,
0,
);
const start = events.find((e) => e.type === "start");
expect(start).toBeDefined();
if (start && start.type === "start") {
expect(start.bin).toBe(BIN_OVERRIDE);
expect(start.argv).toEqual(
expect.arrayContaining(["exec", "--auto"]),
);
expect(start.promptBytes).toBe(
Buffer.byteLength("hello world", "utf8"),
);
}
});
});
describe("deepseek-tui vs non-deepseek-tui close-path distinction", () => {
it("deepseek-tui close-path bypasses parse() entirely — HTML not double-parsed", async () => {
const html = "<html><body>distinct</body></html>";
const events = await driveInvoke(
{ agent: "deepseek-tui", prompt: "test", binOverride: BIN_OVERRIDE },
html,
0,
);
const deltas = events.filter((e) => e.type === "delta");
expect(deltas).toHaveLength(1);
expect(deltas[0]).toMatchObject({ type: "delta", text: html });
const raws = events.filter((e) => e.type === "raw");
expect(raws).toHaveLength(0);
const htmls = events.filter((e) => e.type === "html");
expect(htmls).toHaveLength(0);
});
it("codewhale close-path bypasses parse() entirely — HTML not double-parsed", async () => {
const html = "<html><body>codewhale-distinct</body></html>";
const events = await driveInvoke(
{ agent: "codewhale", prompt: "test", binOverride: BIN_OVERRIDE },
html,
0,
);
const deltas = events.filter((e) => e.type === "delta");
expect(deltas).toHaveLength(1);
expect(deltas[0]).toMatchObject({ type: "delta", text: html });
const raws = events.filter((e) => e.type === "raw");
expect(raws).toHaveLength(0);
const htmls = events.filter((e) => e.type === "html");
expect(htmls).toHaveLength(0);
});
it("aider close-path bypasses parse() entirely — HTML not double-parsed", async () => {
const html = "<html><body>aider-distinct</body></html>";
const events = await driveInvoke(
{ agent: "aider", prompt: "test", binOverride: BIN_OVERRIDE },
html,
0,
);
const deltas = events.filter((e) => e.type === "delta");
expect(deltas).toHaveLength(1);
expect(deltas[0]).toMatchObject({ type: "delta", text: html });
const raws = events.filter((e) => e.type === "raw");
expect(raws).toHaveLength(0);
const htmls = events.filter((e) => e.type === "html");
expect(htmls).toHaveLength(0);
});
});
});
@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest";
import { findCommonPath, resolveCollisionOutput } from "../collision-resolve.js";
describe("findCommonPath", () => {
it('returns "" for empty array', () => {
expect(findCommonPath([])).toBe("");
});
it("returns the resolved path of a single input", () => {
expect(findCommonPath(["/tmp/x"])).toBe("tmp/x");
});
it("finds the common ancestor of two paths with a shared parent", () => {
expect(
findCommonPath(["/home/user/a/b/file.md", "/home/user/a/c/file.md"]),
).toBe("home/user/a");
});
it("returns path.sep when paths share only the root", () => {
expect(findCommonPath(["/foo/a.md", "/bar/b.md"])).toBe("/");
});
});
describe("resolveCollisionOutput", () => {
it("maps a file under a subdirectory relative to the common root", () => {
expect(
resolveCollisionOutput("/repo/a/b/readme.md", "/out", "/repo/a"),
).toBe("/out/b/readme.html");
});
it("preserves deeply nested directory structure under the common root", () => {
expect(
resolveCollisionOutput("/repo/x/y/z/doc.md", "/out", "/repo"),
).toBe("/out/x/y/z/doc.html");
});
it("falls back to plain basename.html when the file is directly in the common root directory", () => {
expect(
resolveCollisionOutput("/repo/a/readme.md", "/out", "/repo/a"),
).toBe("/out/readme.html");
});
it("filters out '..' and '.' segments and produces a clean relative output", () => {
expect(
resolveCollisionOutput("/outside/tmp/a.md", "/out", "/outside"),
).toBe("/out/tmp/a.html");
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { extractHtml } from "../extract-html.js";
describe("extractHtml", () => {
it('returns "" for agent error text ("rate limit exceeded")', () => {
expect(extractHtml("rate limit exceeded")).toBe("");
});
it('returns "" for empty string input', () => {
expect(extractHtml("")).toBe("");
});
it('returns "" for plain text without HTML markers ("Here is your result: some text")', () => {
expect(extractHtml("Here is your result: some text")).toBe("");
});
it('returns "" for non-HTML fenced code block (```json {"a":1} ```)', () => {
expect(extractHtml('```json\n{"a":1}\n```')).toBe("");
});
it("extracts DOCTYPE + full HTML from text with surrounding content", () => {
const input = "Sure!\n<!DOCTYPE html><html><body>ok</body></html>";
expect(extractHtml(input)).toBe("<!DOCTYPE html><html><body>ok</body></html>");
});
it("extracts inner content from fenced HTML code block", () => {
const input = "```html\n<html><body>ok</body></html>\n```";
expect(extractHtml(input)).toBe("<html><body>ok</body></html>");
});
it("returns content starting with < that is valid HTML snippet", () => {
const input = "<div><p>Hello</p></div>";
expect(extractHtml(input)).toBe("<div><p>Hello</p></div>");
});
it("returns HTML content when closing </html> is missing (streaming)", () => {
const input = "<html><body>streaming...";
expect(extractHtml(input)).toBe("<html><body>streaming...");
});
it("returns plain <html> tag content without DOCTYPE", () => {
const input = "<html>\n<body>ok</body>\n</html>";
expect(extractHtml(input)).toBe("<html>\n<body>ok</body>\n</html>");
});
});
+446
View File
@@ -0,0 +1,446 @@
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest";
const {
agentStore,
skillStore,
configStore,
invokeStore,
fsStore,
saveStore,
setAgents,
setSkills,
setConfig,
setInvokeStream,
getFileContents,
getSaveConfigCalls,
resetStores,
} = vi.hoisted(() => {
const agentStore = {
list: [
{
id: "claude",
label: "Claude Code",
vendor: "Anthropic",
available: true,
protocol: "stdin" as const,
models: [] as { id: string; label: string }[],
unsupported: undefined as boolean | undefined,
},
{
id: "hermes",
label: "Hermes",
vendor: "Mature",
available: true,
protocol: "acp" as const,
models: [] as { id: string; label: string }[],
unsupported: true,
},
{
id: "pi",
label: "Pi",
vendor: "Inflection",
available: true,
protocol: "pi-rpc" as const,
models: [] as { id: string; label: string }[],
unsupported: true,
},
{
id: "gemini",
label: "Gemini CLI",
vendor: "Google",
available: false,
protocol: "stdin" as const,
models: [] as { id: string; label: string }[],
unsupported: undefined as boolean | undefined,
},
],
};
const skillStore = {
list: [
{
id: "valid-template",
zhName: "Valid Template",
enName: "Valid",
emoji: "📄",
description: "A valid template",
category: "doc",
scenario: "general",
aspectHint: "",
tags: [] as string[],
},
],
loaded: new Map<string, any>([
[
"valid-template",
{
id: "valid-template",
zhName: "Valid Template",
enName: "Valid",
emoji: "📄",
description: "A valid template",
category: "doc",
scenario: "general",
aspectHint: "",
tags: [],
body: "You are a world-class HTML designer. Output complete HTML.",
},
],
]),
};
const configStore = {
data: {} as Record<string, any>,
saveError: null as Error | null,
};
const invokeStore = {
htmlOutput: "<!DOCTYPE html><html><head></head><body><h1>Test</h1></body></html>",
errorMessage: null as string | null,
exitCode: 0,
};
const fsStore = {
files: new Map<string, string>(),
existing: new Set<string>(),
mkdirCalls: [] as string[],
};
const saveStore = {
calls: [] as Array<Record<string, any>>,
};
const defaults = {
agents: JSON.parse(JSON.stringify(agentStore.list)),
skills: JSON.parse(JSON.stringify(skillStore.list)),
config: {},
};
return {
agentStore,
skillStore,
configStore,
invokeStore,
fsStore,
saveStore,
setAgents: (a: typeof agentStore.list) => {
agentStore.list = a;
},
setSkills: (s: typeof skillStore.list) => {
skillStore.list = s;
},
setConfig: (c: Record<string, any>) => {
configStore.data = c;
},
setInvokeStream: (html: string, err?: string | null, code?: number) => {
invokeStore.htmlOutput = html;
invokeStore.errorMessage = err ?? null;
invokeStore.exitCode = code ?? 0;
},
getFileContents: () => fsStore.files,
getSaveConfigCalls: () => saveStore.calls,
resetStores: () => {
agentStore.list = JSON.parse(JSON.stringify(defaults.agents));
skillStore.list = JSON.parse(JSON.stringify(defaults.skills));
configStore.data = { ...defaults.config };
configStore.saveError = null;
invokeStore.htmlOutput = "<!DOCTYPE html><html><head></head><body><h1>Test</h1></body></html>";
invokeStore.errorMessage = null;
invokeStore.exitCode = 0;
fsStore.files.clear();
fsStore.existing.clear();
fsStore.mkdirCalls = [];
saveStore.calls = [];
},
};
});
vi.mock("../agents-detect.js", () => ({
detectAgents: vi.fn(() => [...agentStore.list]),
}));
vi.mock("../skills-loader.js", () => ({
listSkills: vi.fn(() => [...skillStore.list]),
loadSkill: vi.fn((_dir: string, id: string) => skillStore.loaded.get(id) ?? null),
}));
function makeMockStream(): any {
const chunks: any[] = [];
if (invokeStore.errorMessage) {
chunks.push({ type: "error", message: invokeStore.errorMessage });
} else {
chunks.push({ type: "delta", text: invokeStore.htmlOutput });
chunks.push({ type: "done", code: invokeStore.exitCode });
}
let index = 0;
return {
getReader() {
return {
read() {
if (index < chunks.length) {
return Promise.resolve({ value: chunks[index++], done: false });
}
return Promise.resolve({ value: undefined, done: true });
},
cancel() {},
releaseLock() {},
get closed() {
return index >= chunks.length;
},
};
},
};
}
vi.mock("../agents-invoke.js", () => ({
invokeAgent: vi.fn(() => makeMockStream()),
}));
vi.mock("../config.js", () => ({
loadConfig: vi.fn(() => ({ ...configStore.data })),
saveConfig: vi.fn((c: Record<string, any>) => {
if (configStore.saveError) throw configStore.saveError;
configStore.data = { ...configStore.data, ...c };
saveStore.calls.push({ ...c });
}),
getConfigPath: vi.fn(() => "/fake/config.json"),
}));
vi.mock("node:fs", () => {
const mockReadFileSync = vi.fn((p: string, _enc?: string) => {
if (fsStore.files.has(p)) return fsStore.files.get(p);
return "# Test markdown content";
});
const mockWriteFileSync = vi.fn((p: string, content: string) => {
fsStore.files.set(p, content);
});
const mockExistsSync = vi.fn((p: string) => {
if (fsStore.files.has(p)) return true;
if (typeof p === "string" && p.endsWith("pnpm-workspace.yaml")) return true;
return false;
});
const mockMkdirSync = vi.fn((p: string) => {
fsStore.mkdirCalls.push(p);
});
const mockReaddirSync = vi.fn(() => [] as any[]);
const fsNs = {
readFileSync: mockReadFileSync,
writeFileSync: mockWriteFileSync,
existsSync: mockExistsSync,
mkdirSync: mockMkdirSync,
readdirSync: mockReaddirSync,
};
return {
default: fsNs,
readFileSync: mockReadFileSync,
writeFileSync: mockWriteFileSync,
existsSync: mockExistsSync,
mkdirSync: mockMkdirSync,
readdirSync: mockReaddirSync,
};
});
vi.mock("../prompt.js", () => ({
promptYesNo: vi.fn(async () => false),
promptOverwrite: vi.fn(async () => true),
}));
let main: (args: string[]) => Promise<void>;
let exitCode: number | null;
let stderrLines: string[];
let stdoutLines: string[];
beforeAll(async () => {
const mod = await import("../index.js");
main = mod.main;
});
beforeEach(() => {
exitCode = null;
stderrLines = [];
stdoutLines = [];
resetStores();
vi.spyOn(process, "exit").mockImplementation(
(code?: number | string | null): never => {
const c = typeof code === "number" ? code : 0;
exitCode = c;
throw new Error(`__EXIT_${c}__`);
},
);
vi.spyOn(console, "error").mockImplementation((...args: any[]) => {
stderrLines.push(args.join(" "));
});
vi.spyOn(console, "log").mockImplementation((...args: any[]) => {
stdoutLines.push(args.join(" "));
});
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
});
afterEach(() => {
vi.restoreAllMocks();
});
function stderrContains(substring: string): boolean {
return stderrLines.some((l) => l.includes(substring));
}
function stdoutContains(substring: string): boolean {
return stdoutLines.some((l) => l.includes(substring));
}
describe("main() parameter validation", () => {
it("rejects -o and -d used together", async () => {
await expect(main(["convert", "-o", "out.html", "-d", "out"])).rejects.toThrow("__EXIT_1__");
expect(exitCode).toBe(1);
expect(stderrContains("cannot be used together")).toBe(true);
});
it("rejects -o with multiple input files", async () => {
await expect(main(["convert", "a.md", "b.md", "-o", "out.html"])).rejects.toThrow("__EXIT_1__");
expect(exitCode).toBe(1);
expect(stderrContains("cannot be used with multiple input files")).toBe(true);
});
it('rejects unknown format "xml"', async () => {
await expect(main(["convert", "file.md", "--format", "xml"])).rejects.toThrow("__EXIT_1__");
expect(exitCode).toBe(1);
expect(stderrContains("Unknown format")).toBe(true);
});
it("rejects unknown option", async () => {
await expect(main(["convert", "file.md", "--unknown"])).rejects.toThrow("__EXIT_1__");
expect(exitCode).toBe(1);
expect(stderrContains("Unknown option")).toBe(true);
});
it("rejects unknown command", async () => {
await expect(main(["unknown-command"])).rejects.toThrow("__EXIT_1__");
expect(exitCode).toBe(1);
expect(stderrContains("Unknown command")).toBe(true);
});
});
describe("main() config set-default-agent", () => {
it('rejects unknown agent id "unknown"', async () => {
await expect(main(["config", "set-default-agent", "unknown"])).rejects.toThrow("__EXIT_1__");
expect(exitCode).toBe(1);
expect(stderrContains("Unknown agent")).toBe(true);
});
it('rejects agent that is not installed (gemini)', async () => {
await expect(main(["config", "set-default-agent", "gemini"])).rejects.toThrow("__EXIT_1__");
expect(exitCode).toBe(1);
expect(stderrContains("not installed")).toBe(true);
});
it('rejects agent with unsupported protocol (hermes — acp)', async () => {
await expect(main(["config", "set-default-agent", "hermes"])).rejects.toThrow("__EXIT_1__");
expect(exitCode).toBe(1);
expect(stderrContains("unsupported protocol")).toBe(true);
});
it('rejects agent with unsupported protocol (pi — pi-rpc)', async () => {
await expect(main(["config", "set-default-agent", "pi"])).rejects.toThrow("__EXIT_1__");
expect(exitCode).toBe(1);
expect(stderrContains("unsupported protocol")).toBe(true);
});
it("successfully sets claude as default agent", async () => {
await expect(main(["config", "set-default-agent", "claude"])).resolves.toBeUndefined();
expect(exitCode).toBeNull();
const calls = getSaveConfigCalls();
expect(calls.length).toBeGreaterThanOrEqual(1);
expect(calls.some((c) => c.defaultAgent === "claude")).toBe(true);
});
it("rejects missing agent id", async () => {
await expect(main(["config", "set-default-agent"])).rejects.toThrow("__EXIT_1__");
expect(exitCode).toBe(1);
expect(stderrContains("Specify an agent ID")).toBe(true);
});
});
describe("main() template validation", () => {
it('rejects unknown template "nonexistent"', async () => {
await expect(main(["convert", "file.md", "-t", "nonexistent"])).rejects.toThrow("__EXIT_1__");
expect(exitCode).toBe(1);
expect(stderrContains("Unknown template")).toBe(true);
});
it("rejects when no template specified and no default set", async () => {
setConfig({});
await expect(main(["convert", "file.md"])).rejects.toThrow("__EXIT_1__");
expect(exitCode).toBe(1);
expect(stderrContains("No template specified")).toBe(true);
});
});
describe("main() agent not found", () => {
it('rejects when specified agent "nonexistent" is not available', async () => {
await expect(main(["convert", "file.md", "-t", "valid-template", "-a", "nonexistent"])).rejects.toThrow(
"__EXIT_1__",
);
expect(exitCode).toBe(1);
expect(stderrContains("No available AI agent found")).toBe(true);
});
});
describe("main() config save error handling", () => {
it("exits with error when saveConfig throws", async () => {
configStore.saveError = new Error("disk full");
await expect(main(["config", "set-default-agent", "claude"])).rejects.toThrow("__EXIT_1__");
expect(exitCode).toBe(1);
expect(stderrContains("disk full")).toBe(true);
});
});
describe("main() convert success path with mocked agent", () => {
it("invokes agent and writes output file", async () => {
setInvokeStream("<!DOCTYPE html><html><head></head><body><h1>Hello World</h1></body></html>");
await expect(main(["convert", "file.md", "-t", "valid-template", "-a", "claude"])).resolves.toBeUndefined();
expect(exitCode).toBeNull();
const files = getFileContents();
const outputFile = [...files.keys()].find((k) => k.endsWith(".html"));
expect(outputFile).toBeTruthy();
expect(files.get(outputFile!)).toContain("<h1>Hello World</h1>");
});
});
describe("main() --help and --version", () => {
it("prints help with --help flag", async () => {
await expect(main(["--help"])).resolves.toBeUndefined();
expect(stdoutContains("html-anything — AI-powered Markdown to HTML converter")).toBe(true);
});
it("prints help with -h flag", async () => {
await expect(main(["-h"])).resolves.toBeUndefined();
expect(stdoutContains("USAGE:")).toBe(true);
});
it("prints help with no arguments", async () => {
await expect(main([])).resolves.toBeUndefined();
expect(stdoutContains("COMMANDS:")).toBe(true);
});
it("prints version with --version", async () => {
await expect(main(["--version"])).resolves.toBeUndefined();
expect(stdoutContains("html-anything CLI v")).toBe(true);
});
it("prints version with -v", async () => {
await expect(main(["-v"])).resolves.toBeUndefined();
expect(stdoutContains("html-anything CLI v")).toBe(true);
});
it("prints help from convert --help", async () => {
await expect(main(["convert", "--help"])).resolves.toBeUndefined();
expect(stdoutContains("COMMANDS:")).toBe(true);
});
});
+138
View File
@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const { mockCreateInterface, setAnswer } = vi.hoisted(() => {
let answer = "n";
return {
setAnswer: (a: string) => {
answer = a;
},
mockCreateInterface: vi.fn(() => ({
question: (_q: string, cb: (a: string) => void) => cb(answer),
close: vi.fn(),
})),
};
});
vi.mock("node:readline", () => ({
default: { createInterface: mockCreateInterface },
}));
import { promptYesNo, promptOverwrite } from "../prompt.js";
function setStdinTTY(value: boolean) {
Object.defineProperty(process.stdin, "isTTY", {
value,
configurable: true,
writable: true,
});
}
function setStderrTTY(value: boolean) {
Object.defineProperty(process.stderr, "isTTY", {
value,
configurable: true,
writable: true,
});
}
describe("promptYesNo", () => {
beforeEach(() => {
setStdinTTY(false);
setStderrTTY(false);
setAnswer("n");
});
afterEach(() => {
vi.restoreAllMocks();
});
it("resolves false when stdin.isTTY is false and stderr.isTTY is true", async () => {
setStdinTTY(false);
setStderrTTY(true);
const result = await promptYesNo("Continue?");
expect(result).toBe(false);
});
it("resolves false when stdin.isTTY is true and stderr.isTTY is false", async () => {
setStdinTTY(true);
setStderrTTY(false);
const result = await promptYesNo("Continue?");
expect(result).toBe(false);
});
it("resolves false when both stdin and stderr are not TTY", async () => {
const result = await promptYesNo("Continue?");
expect(result).toBe(false);
});
it("does not throw when both are TTY", () => {
setStdinTTY(true);
setStderrTTY(true);
expect(() => {
promptYesNo("Continue?");
}).not.toThrow();
});
it('resolves true when answer is "y" in TTY mode', async () => {
setStdinTTY(true);
setStderrTTY(true);
setAnswer("y");
const result = await promptYesNo("Continue?");
expect(result).toBe(true);
});
it('resolves false when answer is "n" in TTY mode', async () => {
setStdinTTY(true);
setStderrTTY(true);
setAnswer("n");
const result = await promptYesNo("Continue?");
expect(result).toBe(false);
});
});
describe("promptOverwrite", () => {
beforeEach(() => {
setStdinTTY(false);
setStderrTTY(false);
setAnswer("n");
});
afterEach(() => {
vi.restoreAllMocks();
});
it("resolves true (auto-overwrite) when stdin.isTTY is false and stderr.isTTY is true", async () => {
setStdinTTY(false);
setStderrTTY(true);
const result = await promptOverwrite("/some/file.txt");
expect(result).toBe(true);
});
it("resolves true (auto-overwrite) when stdin.isTTY is true and stderr.isTTY is false", async () => {
setStdinTTY(true);
setStderrTTY(false);
const result = await promptOverwrite("/some/file.txt");
expect(result).toBe(true);
});
it("resolves true (auto-overwrite) when both stdin and stderr are not TTY", async () => {
const result = await promptOverwrite("/some/file.txt");
expect(result).toBe(true);
});
it('resolves true when answer is "y" in TTY mode', async () => {
setStdinTTY(true);
setStderrTTY(true);
setAnswer("y");
const result = await promptOverwrite("/some/file.txt");
expect(result).toBe(true);
});
it('resolves false when answer is "n" in TTY mode', async () => {
setStdinTTY(true);
setStderrTTY(true);
setAnswer("n");
const result = await promptOverwrite("/some/file.txt");
expect(result).toBe(false);
});
});
+501
View File
@@ -0,0 +1,501 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { kwMatches } from "../skills-matcher.js";
const { skillStore, invokeStore, setInvokeResult, resetStores } = vi.hoisted(() => {
const skillStore = {
templates: [
{
id: "resume-modern",
zhName: "极简简历",
enName: "Modern Resume",
emoji: "📄",
description: "现代极简简历, A4 单页, 适合打印或导出 PDF",
category: "resume",
scenario: "personal",
aspectHint: "A4",
tags: ["resume", "cv", "简历"],
},
{
id: "article-magazine",
zhName: "杂志文章",
enName: "Magazine Article",
emoji: "📖",
description: "Substack / Medium 高级感长文排版, 适合公众号、博客发布",
category: "article",
scenario: "marketing",
aspectHint: "A4 / 长页面",
tags: ["blog", "essay", "newsletter", "公众号", "博客", "文章"],
},
{
id: "social-x-post-card",
zhName: "X (Twitter) 帖子卡",
enName: "X / Twitter Post Card",
emoji: "𝕏",
description: "拟真 X 推文卡片 + 互动数据, 适配视频叠加或图卡分享",
category: "card",
scenario: "marketing",
aspectHint: "1280×720 或 1080×1080",
tags: ["twitter", "x", "social", "card", "overlay"],
},
{
id: "deck-swiss-international",
zhName: "瑞士国际主义 Deck",
enName: "Swiss International Deck",
emoji: "🇨🇭",
description: "Swiss International Style presentation, clean grids",
category: "slides",
scenario: "marketing",
aspectHint: "16:9",
tags: ["slides", "deck", "presentation", "swiss"],
},
{
id: "pricing-page",
zhName: "定价页",
enName: "Pricing Page",
emoji: "💳",
description: "三档定价 + 特性对比表 + FAQ",
category: "prototype",
scenario: "sales",
aspectHint: "桌面 1440",
tags: ["pricing", "plans", "定价"],
},
{
id: "team-okrs",
zhName: "团队 OKR 追踪",
enName: "Team OKRs",
emoji: "🎯",
description: "季度 banner + 3 个目标 + KR 进度条 + owner + 状态 pill",
category: "dashboard",
scenario: "product",
aspectHint: "桌面 1440",
tags: ["okr", "objectives", "key results", "目标"],
},
{
id: "eng-runbook",
zhName: "工程 Runbook",
enName: "Engineering Runbook",
emoji: "📕",
description: "服务概述 + alerts 表 + dashboards + 操作命令 + on-call",
category: "doc",
scenario: "engineering",
aspectHint: "长页面",
tags: ["runbook", "ops", "oncall", "sre"],
},
{
id: "kanban-board",
zhName: "看板",
enName: "Kanban Board",
emoji: "📋",
description: "Kanban board with columns",
category: "dashboard",
scenario: "operations",
aspectHint: "桌面 1440",
tags: ["kanban", "board", "todo", "doing", "done"],
},
{
id: "weekly-update",
zhName: "团队周报 Deck",
enName: "Weekly Update Deck",
emoji: "🗓️",
description: "6-8 页横向滑动周报: 已发布 / 进行中 / 阻塞 / 指标 / 求助",
category: "slides",
scenario: "operations",
aspectHint: "16:9 ×8",
tags: ["weekly", "周报", "status"],
},
{
id: "docs-page",
zhName: "技术文档页",
enName: "Documentation Page",
emoji: "📚",
description: "技术文档页, 带侧边栏导航",
category: "doc",
scenario: "engineering",
aspectHint: "桌面 1440",
tags: ["docs", "documentation", "doc"],
},
] as const,
};
const invokeStore = {
summaryText: "",
errorMessage: null as string | null,
};
const defaults = {
templates: JSON.parse(JSON.stringify(skillStore.templates)),
};
return {
skillStore,
invokeStore,
setInvokeResult: (summary: string, err?: string | null) => {
invokeStore.summaryText = summary;
invokeStore.errorMessage = err ?? null;
},
resetStores: () => {
skillStore.templates = JSON.parse(JSON.stringify(defaults.templates));
invokeStore.summaryText = "";
invokeStore.errorMessage = null;
},
};
});
vi.mock("../skills-loader.js", () => ({
listSkills: vi.fn(() => [...skillStore.templates]),
loadSkill: vi.fn((_dir: string, id: string) => {
const t = skillStore.templates.find((s) => s.id === id);
if (!t) return null;
return { ...t, body: "You are a world-class HTML designer. Output complete HTML.", exampleMd: undefined, exampleHtml: undefined };
}),
}));
vi.mock("../agents-invoke.js", () => ({
invokeAgent: vi.fn(() => {
const chunks: any[] = [];
if (invokeStore.errorMessage) {
chunks.push({ type: "error", message: invokeStore.errorMessage });
} else {
chunks.push({ type: "delta", text: invokeStore.summaryText });
chunks.push({ type: "done", code: 0 });
}
let index = 0;
return {
getReader() {
return {
read() {
if (index < chunks.length) {
return Promise.resolve({ value: chunks[index++], done: false });
}
return Promise.resolve({ value: undefined, done: true });
},
cancel() {},
releaseLock() {},
get closed() {
return index >= chunks.length;
},
};
},
};
}),
}));
import { matchTemplate } from "../skills-matcher.js";
const SKILLS_DIR = "/fake/skills/dir";
const AGENT_ID = "claude";
beforeEach(() => {
resetStores();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("kwMatches", () => {
it("matches ASCII keyword on word boundary", () => {
expect(kwMatches("I need a resume template", "resume")).toBe(true);
});
it("does NOT match ASCII keyword as substring inside another word", () => {
expect(kwMatches("the document is ready", "doc")).toBe(false);
});
it("matches standalone ASCII keyword at start of text", () => {
expect(kwMatches("doc is important", "doc")).toBe(true);
});
it("matches standalone ASCII keyword at end of text", () => {
expect(kwMatches("read the doc", "doc")).toBe(true);
});
it("does NOT match bare 'x' inside English words", () => {
expect(kwMatches("next example text", "x")).toBe(false);
});
it("does NOT match bare 'x' inside complex words", () => {
expect(kwMatches("experience index box max", "x")).toBe(false);
});
it("matches standalone 'x' as a word", () => {
expect(kwMatches("platform x is great", "x")).toBe(true);
});
it("does NOT match 'app' as substring in 'happy'", () => {
expect(kwMatches("happy developer", "app")).toBe(false);
});
it("does NOT match 'live' inside 'deliver'", () => {
expect(kwMatches("will deliver on time", "live")).toBe(false);
});
it("matches standalone 'live' as a word", () => {
expect(kwMatches("watch it live now", "live")).toBe(true);
});
it("does NOT match 'soft' inside 'software'", () => {
expect(kwMatches("software engineering", "soft")).toBe(false);
});
it("does NOT match 'red' inside 'required'", () => {
expect(kwMatches("required reading", "red")).toBe(false);
});
it("does NOT match 'done' as substring in 'abandoned'", () => {
expect(kwMatches("project abandoned", "done")).toBe(false);
});
it("matches standalone 'done' as a word", () => {
expect(kwMatches("task is done", "done")).toBe(true);
});
it("does NOT match 'todo' as substring in 'mastodon'", () => {
expect(kwMatches("use mastodon", "todo")).toBe(false);
});
it("uses substring matching for CJK characters", () => {
expect(kwMatches("这是一份简历内容", "简历")).toBe(true);
});
it("uses substring matching for mixed CJK in text", () => {
expect(kwMatches("关于定价方案", "定价")).toBe(true);
});
it("case-insensitive for ASCII keywords", () => {
expect(kwMatches("I need a RESUME", "resume")).toBe(true);
});
it("handles special regex characters in keyword safely", () => {
expect(kwMatches("this is 8-bit style", "8-bit")).toBe(true);
expect(kwMatches("use 8-bit encoding", "8-bit")).toBe(true);
});
});
describe("matchTemplate strong-signal matching", () => {
it("matches Chinese resume content to resume-modern", async () => {
const result = await matchTemplate(
"简历\n工作经历: 某公司\n教育背景: 清华",
skillStore.templates as any,
SKILLS_DIR,
AGENT_ID,
);
expect(result.templateId).toBe("resume-modern");
});
it("matches English resume (CV) content to resume-modern", async () => {
const result = await matchTemplate(
"My CV\nWork Experience at Google\nEducation: MIT",
skillStore.templates as any,
SKILLS_DIR,
AGENT_ID,
);
expect(result.templateId).toBe("resume-modern");
});
it("matches pricing content to pricing-page", async () => {
const result = await matchTemplate(
"定价方案\n免费版: 100次\n专业版: 10000次",
skillStore.templates as any,
SKILLS_DIR,
AGENT_ID,
);
expect(result.templateId).toBe("pricing-page");
});
it("matches OKR content to team-okrs", async () => {
const result = await matchTemplate(
"Q1 OKRs\n目标1: 用户增长\n关键结果: DAU +50%",
skillStore.templates as any,
SKILLS_DIR,
AGENT_ID,
);
expect(result.templateId).toBe("team-okrs");
});
it("matches runbook content to eng-runbook", async () => {
const result = await matchTemplate(
"# Payment Service Runbook\nAlerts:\n- P0: 5xx > 1%\nOn-call: Alice",
skillStore.templates as any,
SKILLS_DIR,
AGENT_ID,
);
expect(result.templateId).toBe("eng-runbook");
});
});
describe("matchTemplate word-boundary prevents false positives", () => {
it("does NOT match English text with 'x' to social-x-post-card", async () => {
const result = await matchTemplate(
"# Building Next-Generation Developer Tools\n\nOur team is exploring ways to enhance the developer experience with intelligent tooling that understands context and provides context-aware suggestions.\n\nKey features include fast text processing with minimal latency and excellent accuracy on complex tasks.",
skillStore.templates as any,
SKILLS_DIR,
AGENT_ID,
);
expect(result.templateId).not.toBe("social-x-post-card");
});
it("does NOT match content with 'document' word to docs-page via 'doc' substring", async () => {
const docsOnly = skillStore.templates.filter((t) => t.id === "docs-page");
const result = await matchTemplate(
"this document is very long\nand has many sections",
docsOnly as any,
SKILLS_DIR,
AGENT_ID,
);
expect(result.templateId).toBe("docs-page");
});
it("matches docs-page via full word 'documentation'", async () => {
const result = await matchTemplate(
"Project Documentation\n\nSetup\nUsage\nAPI Reference",
skillStore.templates as any,
SKILLS_DIR,
AGENT_ID,
);
expect(result.templateId).toBe("docs-page");
});
it("does NOT match 'app' substring in 'happy' to mobile-app", async () => {
const result = await matchTemplate(
"# Project Happy\n\nOur team is happy to announce the new release.",
skillStore.templates as any,
SKILLS_DIR,
AGENT_ID,
);
expect(result.templateId).not.toBe("mobile-app");
});
it("does NOT match 'live' inside 'deliver' to live-dashboard", async () => {
const result = await matchTemplate(
"# Delivery Process\n\nWe deliver projects on time with quality.",
skillStore.templates as any,
SKILLS_DIR,
AGENT_ID,
);
expect(result.templateId).not.toBe("live-dashboard");
});
it("does NOT match content with 'done' and 'doing' to kanban-board", async () => {
const only = skillStore.templates.filter((t) => t.id === "kanban-board");
const result = await matchTemplate(
"I have done the work and am doing more.\nThe report is done.",
only as any,
SKILLS_DIR,
AGENT_ID,
);
expect(result.templateId).toBe("kanban-board");
});
});
describe("matchTemplate blog/article matching", () => {
it("matches blog content to article-magazine (contains 公众号, 写作)", async () => {
const result = await matchTemplate(
"# AI Agent 时代的文档写作\n\n在 AI agent 接管编码工作的今天,文档的最终产出格式应该是什么?\n\n## 实践建议\n\n- 使用 HTML Anything 一键转换\n- 导出到公众号、推特、知乎",
skillStore.templates as any,
SKILLS_DIR,
AGENT_ID,
);
expect(result.templateId).toBe("article-magazine");
});
});
describe("matchTemplate fallback matching", () => {
it("falls through to deck-swiss-international for generic content", async () => {
const result = await matchTemplate(
"# Generic Project\n\nSome random text about things. Nothing specific.",
skillStore.templates as any,
SKILLS_DIR,
AGENT_ID,
);
expect(result.templateId).toBe("deck-swiss-international");
});
it("returns confidence <= 10", async () => {
const result = await matchTemplate(
"简历\n工作经历: ABC公司\n教育背景: 北京大学",
skillStore.templates as any,
SKILLS_DIR,
AGENT_ID,
);
expect(result.confidence).toBeLessThanOrEqual(10);
});
});
describe("matchTemplate short content handling", () => {
it("returns result even for very short content", async () => {
const result = await matchTemplate(
"hello",
skillStore.templates as any,
SKILLS_DIR,
AGENT_ID,
);
expect(result.templateId).toBeTruthy();
expect(result.confidence).toBeGreaterThanOrEqual(1);
});
it("returns result for empty content", async () => {
const result = await matchTemplate(
"",
skillStore.templates as any,
SKILLS_DIR,
AGENT_ID,
);
expect(result.templateId).toBeTruthy();
});
});
describe("matchTemplate --force-ai", () => {
it("with --force-ai reaches AI summary instead of rule match", async () => {
setInvokeResult("技术博客");
const result = await matchTemplate(
"# AI Agent 时代的文档写作\n\n在 AI agent 接管编码工作的今天,文档的最终产出格式应该是什么?\n\n## 为什么 HTML 比 Markdown 更好\n\nMarkdown 是一个好的草稿格式,但它无法控制排版、色彩、动画和阅读体验。",
skillStore.templates as any,
SKILLS_DIR,
AGENT_ID,
true,
);
expect(result.templateId).toBeTruthy();
expect(result.reason).toContain("最佳匹配:");
expect(result.reason).not.toContain("关键词命中:");
});
it("with --force-ai and AI error falls back to rule match", async () => {
setInvokeResult("", "Some error");
const result = await matchTemplate(
"简历\n工作经历: ABC公司\n教育背景: 北京大学",
skillStore.templates as any,
SKILLS_DIR,
AGENT_ID,
true,
);
expect(result.templateId).toBe("resume-modern");
});
});
describe("matchTemplate reason output", () => {
it("includes keyword hits in strong-signal reason", async () => {
const result = await matchTemplate(
"个人简历\n\n工作经历: ABC科技有限公司, 高级前端工程师, 2021年至今\n教育背景: 北京大学, 计算机科学学士, 2014-2018",
skillStore.templates as any,
SKILLS_DIR,
AGENT_ID,
);
expect(result.reason).toContain("关键词命中:");
expect(result.reason).toContain("简历");
});
it("includes zhName in result", async () => {
const result = await matchTemplate(
"简历\n工作经历: ABC公司",
skillStore.templates as any,
SKILLS_DIR,
AGENT_ID,
);
expect(result.zhName).toBe("极简简历");
});
});
+389
View File
@@ -0,0 +1,389 @@
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import path, { delimiter, join } from "node:path";
/**
* Agent detection — adapted from next/src/lib/agents/detect.ts
*/
export type AgentProtocol = "stdin" | "argv" | "argv-message" | "acp" | "pi-rpc";
export type ModelOption = { id: string; label: string };
export const DEFAULT_MODEL: ModelOption = { id: "default", label: "Default (CLI config)" };
export type AgentDef = {
id: string;
label: string;
bin: string;
fallbackBins?: string[];
envOverride?: string;
vendor: string;
protocol?: AgentProtocol;
fallbackModels: ModelOption[];
};
export const AGENTS: AgentDef[] = [
{
id: "claude",
label: "Claude Code",
bin: "claude",
fallbackBins: ["openclaude"],
envOverride: "CLAUDE_BIN",
vendor: "Anthropic",
fallbackModels: [
DEFAULT_MODEL,
{ id: "sonnet", label: "Sonnet (alias)" },
{ id: "opus", label: "Opus (alias)" },
{ id: "haiku", label: "Haiku (alias)" },
{ id: "claude-opus-4-7", label: "claude-opus-4-7" },
{ id: "claude-sonnet-4-6", label: "claude-sonnet-4-6" },
{ id: "claude-haiku-4-5", label: "claude-haiku-4-5" },
],
},
{
id: "openclaw",
label: "OpenClaw",
bin: "openclaw",
envOverride: "OPENCLAW_BIN",
vendor: "OpenClaw multi-channel agent gateway",
protocol: "argv-message",
fallbackModels: [
DEFAULT_MODEL,
{ id: "openrouter/anthropic/claude-opus-4.7", label: "Opus 4.7 (OpenRouter)" },
{ id: "openrouter/anthropic/claude-sonnet-4.6", label: "Sonnet 4.6 (OpenRouter)" },
{ id: "openrouter/anthropic/claude-haiku-4.5", label: "Haiku 4.5 (OpenRouter)" },
],
},
{
id: "codex",
label: "OpenAI Codex",
bin: "codex",
envOverride: "CODEX_BIN",
vendor: "OpenAI",
fallbackModels: [
DEFAULT_MODEL,
{ id: "gpt-5.5", label: "gpt-5.5" },
{ id: "gpt-5.4", label: "gpt-5.4" },
{ id: "gpt-5.4-mini", label: "gpt-5.4-mini" },
{ id: "gpt-5.3-codex", label: "gpt-5.3-codex" },
{ id: "gpt-5-codex", label: "gpt-5-codex" },
{ id: "gpt-5", label: "gpt-5" },
{ id: "o3", label: "o3" },
{ id: "o4-mini", label: "o4-mini" },
],
},
{
id: "cursor-agent",
label: "Cursor Agent",
bin: "cursor-agent",
envOverride: "CURSOR_AGENT_BIN",
vendor: "Cursor",
fallbackModels: [
DEFAULT_MODEL,
{ id: "auto", label: "auto" },
{ id: "sonnet-4", label: "sonnet-4" },
{ id: "sonnet-4-thinking", label: "sonnet-4-thinking" },
{ id: "gpt-5", label: "gpt-5" },
],
},
{
id: "gemini",
label: "Gemini CLI",
bin: "gemini",
envOverride: "GEMINI_BIN",
vendor: "Google",
fallbackModels: [
DEFAULT_MODEL,
{ id: "gemini-2.5-pro", label: "gemini-2.5-pro" },
{ id: "gemini-2.5-flash", label: "gemini-2.5-flash" },
],
},
{
id: "copilot",
label: "GitHub Copilot CLI",
bin: "copilot",
envOverride: "COPILOT_BIN",
vendor: "GitHub",
fallbackModels: [
DEFAULT_MODEL,
{ id: "claude-sonnet-4.6", label: "Claude Sonnet 4.6" },
{ id: "gpt-5.2", label: "GPT-5.2" },
],
},
{
id: "opencode",
label: "OpenCode",
bin: "opencode-cli",
fallbackBins: ["opencode"],
envOverride: "OPENCODE_BIN",
vendor: "Open",
fallbackModels: [
DEFAULT_MODEL,
{ id: "anthropic/claude-sonnet-4-5", label: "anthropic/claude-sonnet-4-5" },
{ id: "openai/gpt-5", label: "openai/gpt-5" },
{ id: "google/gemini-2.5-pro", label: "google/gemini-2.5-pro" },
],
},
{
id: "qwen",
label: "Qwen Coder",
bin: "qwen",
envOverride: "QWEN_BIN",
vendor: "Alibaba",
fallbackModels: [
DEFAULT_MODEL,
{ id: "qwen3-coder-plus", label: "qwen3-coder-plus" },
{ id: "qwen3-coder-flash", label: "qwen3-coder-flash" },
],
},
{
id: "qoder",
label: "Qoder CLI",
bin: "qodercli",
envOverride: "QODER_BIN",
vendor: "Qoder",
fallbackModels: [
DEFAULT_MODEL,
{ id: "lite", label: "Lite" },
{ id: "efficient", label: "Efficient" },
{ id: "auto", label: "Auto" },
{ id: "performance", label: "Performance" },
{ id: "ultimate", label: "Ultimate" },
],
},
{
id: "codewhale",
label: "CodeWhale",
bin: "codewhale",
fallbackBins: ["deepseek-tui"],
envOverride: "CODEWHALE_BIN",
vendor: "CodeWhale",
protocol: "argv",
fallbackModels: [
DEFAULT_MODEL,
{ id: "deepseek-v4-pro", label: "deepseek-v4-pro" },
{ id: "deepseek-v4-flash", label: "deepseek-v4-flash" },
],
},
{
id: "deepseek-tui",
label: "DeepSeek TUI",
bin: "deepseek-tui",
fallbackBins: ["codewhale"],
envOverride: "DEEPSEEK_TUI_BIN",
vendor: "DeepSeek",
protocol: "argv",
fallbackModels: [
DEFAULT_MODEL,
{ id: "deepseek-v4-pro", label: "deepseek-v4-pro" },
{ id: "deepseek-v4-flash", label: "deepseek-v4-flash" },
],
},
{
id: "aider",
label: "Aider",
bin: "aider",
vendor: "Aider",
fallbackModels: [
DEFAULT_MODEL,
{ id: "claude-sonnet-4-5", label: "claude-sonnet-4-5" },
{ id: "gpt-5", label: "gpt-5" },
{ id: "deepseek/deepseek-chat", label: "deepseek/deepseek-chat" },
],
},
{
id: "hermes",
label: "Hermes",
bin: "hermes",
envOverride: "HERMES_BIN",
vendor: "Mature",
protocol: "acp",
fallbackModels: [
DEFAULT_MODEL,
{ id: "openai-codex:gpt-5.5", label: "gpt-5.5 (openai-codex)" },
{ id: "openai-codex:gpt-5.4", label: "gpt-5.4 (openai-codex)" },
],
},
{
id: "kimi",
label: "Kimi CLI",
bin: "kimi",
envOverride: "KIMI_BIN",
vendor: "Moonshot",
protocol: "acp",
fallbackModels: [
DEFAULT_MODEL,
{ id: "kimi-k2-turbo-preview", label: "kimi-k2-turbo-preview" },
{ id: "moonshot-v1-8k", label: "moonshot-v1-8k" },
{ id: "moonshot-v1-32k", label: "moonshot-v1-32k" },
],
},
{
id: "devin",
label: "Devin for Terminal",
bin: "devin",
envOverride: "DEVIN_BIN",
vendor: "Cognition",
protocol: "acp",
fallbackModels: [
DEFAULT_MODEL,
{ id: "adaptive", label: "adaptive" },
{ id: "swe", label: "swe" },
{ id: "opus", label: "opus" },
{ id: "sonnet", label: "sonnet" },
{ id: "codex", label: "codex" },
{ id: "gpt", label: "gpt" },
{ id: "gemini", label: "gemini" },
],
},
{
id: "kiro",
label: "Kiro CLI",
bin: "kiro-cli",
envOverride: "KIRO_BIN",
vendor: "AWS",
protocol: "acp",
fallbackModels: [DEFAULT_MODEL],
},
{
id: "kilo",
label: "Kilo",
bin: "kilo",
envOverride: "KILO_BIN",
vendor: "Kilo",
protocol: "acp",
fallbackModels: [DEFAULT_MODEL],
},
{
id: "vibe",
label: "Mistral Vibe CLI",
bin: "vibe-acp",
envOverride: "VIBE_BIN",
vendor: "Mistral",
protocol: "acp",
fallbackModels: [DEFAULT_MODEL],
},
{
id: "pi",
label: "Pi",
bin: "pi",
envOverride: "PI_BIN",
vendor: "Inflection",
protocol: "pi-rpc",
fallbackModels: [
DEFAULT_MODEL,
{ id: "anthropic/claude-sonnet-4-5", label: "Claude Sonnet 4.5" },
{ id: "anthropic/claude-opus-4-5", label: "Claude Opus 4.5" },
{ id: "openai/gpt-5", label: "GPT-5" },
{ id: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" },
],
},
];
function userToolchainDirs(): string[] {
const home = homedir();
const env = process.env;
const dirs: string[] = [];
const vp = env.VP_HOME?.trim();
if (vp) dirs.push(join(vp, "bin"));
const npmPrefix = env.NPM_CONFIG_PREFIX?.trim();
if (npmPrefix) {
dirs.push(join(npmPrefix, "bin"), npmPrefix);
}
dirs.push(
join(home, ".local/bin"),
join(home, ".vite-plus/bin"),
join(home, ".opencode/bin"),
join(home, ".bun/bin"),
join(home, ".volta/bin"),
join(home, ".asdf/shims"),
join(home, "Library/pnpm"),
join(home, ".cargo/bin"),
join(home, ".npm-global/bin"),
join(home, ".npm-packages/bin"),
join(home, ".claude/local"),
);
if (process.platform === "win32") {
const scoopRoot = env.SCOOP?.trim() || join(home, "scoop");
const globalScoopRoot = env.SCOOP_GLOBAL?.trim() || "C:\\ProgramData\\scoop";
const appData = env.APPDATA?.trim();
dirs.push(
join(scoopRoot, "shims"),
join(scoopRoot, "apps", "nodejs", "current"),
join(scoopRoot, "apps", "nodejs-lts", "current"),
join(globalScoopRoot, "shims"),
join(globalScoopRoot, "apps", "nodejs", "current"),
);
if (appData) dirs.push(join(appData, "npm"));
} else {
dirs.push("/opt/homebrew/bin", "/usr/local/bin");
}
return dirs;
}
export function resolveOnPath(bin: string): string | null {
const exts =
process.platform === "win32"
? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")
: [""];
const seen = new Set<string>();
const dirs = [
...(process.env.PATH ?? "").split(delimiter),
...userToolchainDirs(),
].filter((d) => d && !seen.has(d) && (seen.add(d), true));
for (const d of dirs) {
for (const e of exts) {
const full = path.join(d, bin + e);
try {
if (existsSync(full)) return full;
} catch {}
}
}
return null;
}
export type DetectedAgent = {
id: string;
label: string;
vendor: string;
available: boolean;
path?: string;
resolvedBin?: string;
protocol: AgentProtocol;
models: ModelOption[];
unsupported?: boolean;
};
export function detectAgents(): DetectedAgent[] {
return AGENTS.map((a): DetectedAgent => {
const protocol = a.protocol ?? "stdin";
const unsupported = protocol === "acp" || protocol === "pi-rpc";
const base = {
id: a.id,
label: a.label,
vendor: a.vendor,
protocol,
models: a.fallbackModels,
unsupported: unsupported || undefined,
};
const override = a.envOverride ? process.env[a.envOverride] : undefined;
if (override) {
if (existsSync(override)) {
return { ...base, available: true, path: override, resolvedBin: a.bin };
}
const p = resolveOnPath(override);
if (p) {
return { ...base, available: true, path: p, resolvedBin: override };
}
}
const candidates = [a.bin, ...(a.fallbackBins ?? [])];
for (const c of candidates) {
const p = resolveOnPath(c);
if (p) {
return { ...base, available: true, path: p, resolvedBin: c };
}
}
return { ...base, available: false };
});
}
+629
View File
@@ -0,0 +1,629 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { resolveOnPath, AGENTS, type AgentDef, type AgentProtocol } from "./agents-detect.js";
export type InvokeOpts = {
agent: string;
prompt: string;
cwd?: string;
model?: string;
signal?: AbortSignal;
binOverride?: string;
};
type BinResolution =
| { kind: "ok"; bin: string }
| { kind: "override-missing"; tried: string }
| { kind: "not-found" };
function resolveBinForAgent(
def: (typeof AGENTS)[number],
binOverride: string | undefined,
): BinResolution {
const tryPath = (p: string | undefined): string | null => {
if (!p) return null;
const trimmed = p.trim();
if (!trimmed) return null;
if (/^([a-zA-Z]:[\\/]|[\\/])/.test(trimmed)) {
return existsSync(trimmed) ? trimmed : null;
}
if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.startsWith(".")) {
const resolved = path.resolve(trimmed);
return existsSync(resolved) ? resolved : null;
}
return resolveOnPath(trimmed);
};
if (binOverride && binOverride.trim()) {
const fromOverride = tryPath(binOverride);
if (fromOverride) return { kind: "ok", bin: fromOverride };
return { kind: "override-missing", tried: binOverride.trim() };
}
if (def.envOverride) {
const fromEnv = tryPath(process.env[def.envOverride]);
if (fromEnv) return { kind: "ok", bin: fromEnv };
}
for (const c of [def.bin, ...(def.fallbackBins ?? [])]) {
const found = resolveOnPath(c);
if (found) return { kind: "ok", bin: found };
}
return { kind: "not-found" };
}
export type InvokeEvent =
| { type: "start"; bin: string; argv: string[]; promptBytes: number }
| { type: "delta"; text: string }
| { type: "html"; text: string }
| { type: "meta"; key: string; value: unknown }
| { type: "stderr"; text: string }
| { type: "raw"; text: string }
| { type: "done"; code: number | null }
| { type: "error"; message: string };
// ─── argv builder ────────────────────────────────────────────────────
type AgentArgvOpts = {
model?: string;
openclawAgentId?: string;
};
class UnsupportedAgentProtocolError extends Error {
constructor(public readonly agent: string, public readonly protocol: string) {
super(
`${agent} uses the ${protocol} protocol, which is not yet wired up in this build. ` +
`Pick one of: claude / codex / cursor-agent / gemini / copilot / opencode / qwen / qoder / codewhale / deepseek-tui / aider.`,
);
}
}
function buildArgv(agent: string, opts: AgentArgvOpts = {}): string[] {
const { model } = opts;
switch (agent) {
case "claude":
return [
"-p",
"--output-format",
"stream-json",
"--verbose",
"--include-partial-messages",
"--permission-mode",
"bypassPermissions",
...(model ? ["--model", model] : []),
];
case "openclaw":
return [
"agent",
"--local",
"--json",
"--agent",
opts.openclawAgentId ?? "main",
...(model ? ["--model", model] : []),
];
case "codex":
return [
"exec",
"--json",
"--skip-git-repo-check",
"--sandbox",
"workspace-write",
"-c",
"sandbox_workspace_write.network_access=true",
...(model ? ["--model", model] : []),
];
case "cursor-agent":
return [
"--print",
"--output-format",
"stream-json",
"--stream-partial-output",
"--force",
"--trust",
...(model ? ["--model", model] : []),
];
case "gemini":
return [
"--output-format",
"stream-json",
"--yolo",
...(model ? ["--model", model] : []),
];
case "copilot":
return [
"--allow-all-tools",
"--output-format",
"json",
...(model ? ["--model", model] : []),
];
case "opencode":
return [
"run",
"--format",
"json",
"--dangerously-skip-permissions",
...(model ? ["--model", model] : []),
"-",
];
case "qwen":
return ["--yolo", ...(model ? ["--model", model] : []), "-"];
case "aider":
return [
"--no-pretty",
"--no-stream",
"--yes-always",
"--message-file",
"-",
...(model ? ["--model", model] : []),
];
case "qoder":
return [
"-p",
"--output-format",
"stream-json",
"--yolo",
...(model ? ["--model", model] : []),
];
case "codewhale":
case "deepseek-tui":
return ["exec", "--auto", ...(model ? ["--model", model] : [])];
case "hermes":
case "kimi":
case "devin":
case "kiro":
case "kilo":
case "vibe":
throw new UnsupportedAgentProtocolError(agent, "ACP JSON-RPC");
case "pi":
throw new UnsupportedAgentProtocolError(agent, "pi-rpc");
default:
throw new Error(`unknown agent: ${agent}`);
}
}
function envFor(agent: string): NodeJS.ProcessEnv {
const base = { ...process.env };
if (agent === "gemini") base.GEMINI_CLI_TRUST_WORKSPACE = "true";
return base;
}
// ─── stdout parser ────────────────────────────────────────────────────
type AgentParse =
| { kind: "delta"; text: string }
| { kind: "meta"; key: string; value: unknown }
| { kind: "html"; text: string }
| { kind: "noise" };
type ParseState = { sawStreamEventText?: boolean };
function rescueHtmlFromToolUse(
content: Array<{ type?: string; name?: string; input?: unknown }> | undefined,
): string {
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const block of content) {
if (!block || block.type !== "tool_use") continue;
const name = (block.name ?? "").toLowerCase();
if (
name !== "write" &&
name !== "create_file" &&
name !== "createfile" &&
name !== "writefile" &&
name !== "write_file" &&
name !== "filewrite"
)
continue;
const input = block.input as Record<string, unknown> | undefined;
if (!input || typeof input !== "object") continue;
const path = String(input.file_path ?? input.path ?? input.filename ?? "").toLowerCase();
if (path && !/\.(html?|htm)$/.test(path)) continue;
const text =
typeof input.content === "string"
? input.content
: typeof input.text === "string"
? input.text
: typeof input.file_content === "string"
? input.file_content
: "";
if (text) parts.push(text);
}
return parts.join("");
}
function parseLineWithState(agent: string, line: string, state: ParseState): AgentParse[] {
const trimmed = line.trim();
if (!trimmed) return [];
if (agent === "aider" || agent === "codewhale" || agent === "deepseek-tui") {
return [{ kind: "delta", text: trimmed.endsWith("\n") ? trimmed : trimmed + "\n" }];
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return [{ kind: "noise" }];
}
if (!parsed || typeof parsed !== "object") return [];
const obj = parsed as Record<string, unknown>;
const out: AgentParse[] = [];
if (agent === "claude") {
if (obj.type === "system" && obj.subtype === "init") {
out.push({ kind: "meta", key: "model", value: obj.model });
out.push({ kind: "meta", key: "session", value: obj.session_id });
if (obj.cwd) out.push({ kind: "meta", key: "cwd", value: obj.cwd });
}
if (obj.type === "stream_event" && obj.event && typeof obj.event === "object") {
const ev = obj.event as { type?: string; delta?: { type?: string; text?: string; thinking?: string } };
if (ev.type === "content_block_delta" && ev.delta?.type === "text_delta" && typeof ev.delta.text === "string") {
state.sawStreamEventText = true;
out.push({ kind: "delta", text: ev.delta.text });
} else if (ev.type === "content_block_delta" && ev.delta?.type === "thinking_delta") {
out.push({ kind: "meta", key: "thinking", value: ev.delta.thinking });
}
}
if (obj.type === "assistant" && obj.message && typeof obj.message === "object") {
const msg = obj.message as {
content?: Array<{ type?: string; text?: string; name?: string; input?: unknown }>;
usage?: Record<string, number>;
model?: string;
};
const toolHtml = rescueHtmlFromToolUse(msg.content);
if (toolHtml) {
out.push({ kind: "html", text: toolHtml });
state.sawStreamEventText = true;
}
if (!state.sawStreamEventText) {
const text = (msg.content ?? [])
.filter((c) => c?.type === "text" && typeof c.text === "string")
.map((c) => c.text!)
.join("");
if (text) out.push({ kind: "delta", text });
}
if (msg.usage) out.push({ kind: "meta", key: "usage_partial", value: msg.usage });
}
if (obj.type === "result") {
if (obj.usage) out.push({ kind: "meta", key: "usage", value: obj.usage });
if (typeof obj.duration_ms === "number") out.push({ kind: "meta", key: "duration_ms", value: obj.duration_ms });
if (typeof obj.total_cost_usd === "number") out.push({ kind: "meta", key: "cost_usd", value: obj.total_cost_usd });
if (typeof obj.subtype === "string") out.push({ kind: "meta", key: "result", value: obj.subtype });
}
}
if (agent === "codex") {
if (obj.type === "item.completed" && obj.item && typeof obj.item === "object") {
const item = obj.item as { item_type?: string; type?: string; text?: string };
const itemType = item.item_type ?? item.type;
if (
(itemType === "assistant_message" || itemType === "agent_message") &&
typeof item.text === "string"
) {
out.push({ kind: "delta", text: item.text });
}
}
if (obj.type === "item.delta" && typeof obj.text === "string") {
out.push({ kind: "delta", text: obj.text });
}
if (obj.msg && typeof obj.msg === "object") {
const msg = obj.msg as { type?: string; message?: string };
if (msg.type === "agent_message" && typeof msg.message === "string") {
out.push({ kind: "delta", text: msg.message });
}
}
if (obj.type === "task_complete" && obj.usage) {
out.push({ kind: "meta", key: "usage", value: obj.usage });
}
if (obj.type === "turn.completed" && obj.usage) {
out.push({ kind: "meta", key: "usage", value: obj.usage });
}
}
if (agent === "cursor-agent" || agent === "gemini") {
if (obj.type === "stream_event" && obj.event && typeof obj.event === "object") {
const ev = obj.event as { type?: string; delta?: { type?: string; text?: string } };
if (ev.delta?.type === "text_delta" && typeof ev.delta.text === "string") {
state.sawStreamEventText = true;
out.push({ kind: "delta", text: ev.delta.text });
}
}
if (obj.type === "assistant" && obj.message && typeof obj.message === "object") {
const msg = obj.message as { content?: Array<{ type?: string; text?: string; name?: string; input?: unknown }> };
const toolHtml = rescueHtmlFromToolUse(msg.content);
if (toolHtml) {
out.push({ kind: "html", text: toolHtml });
state.sawStreamEventText = true;
}
if (!state.sawStreamEventText) {
const text = (msg.content ?? [])
.filter((c) => c?.type === "text" && typeof c.text === "string")
.map((c) => c.text!)
.join("");
if (text) out.push({ kind: "delta", text });
}
}
if (typeof obj.text === "string" && !state.sawStreamEventText && obj.type !== "assistant") {
out.push({ kind: "delta", text: obj.text as string });
}
}
if (agent === "copilot") {
if (typeof obj.response === "string") out.push({ kind: "delta", text: obj.response });
if (typeof obj.text === "string") out.push({ kind: "delta", text: obj.text });
}
if (agent === "opencode" || agent === "qwen") {
if (typeof obj.text === "string") out.push({ kind: "delta", text: obj.text });
if (typeof obj.content === "string") out.push({ kind: "delta", text: obj.content });
if (typeof obj.message === "string") out.push({ kind: "delta", text: obj.message });
}
if (agent === "qoder") {
if (obj.type === "system" && obj.subtype === "init") {
if (obj.model) out.push({ kind: "meta", key: "model", value: obj.model });
if (obj.session_id) out.push({ kind: "meta", key: "session", value: obj.session_id });
}
if (obj.type === "stream_event" && obj.event && typeof obj.event === "object") {
const ev = obj.event as { type?: string; delta?: { type?: string; text?: string } };
if (ev.type === "content_block_delta" && ev.delta?.type === "text_delta" && typeof ev.delta.text === "string") {
state.sawStreamEventText = true;
out.push({ kind: "delta", text: ev.delta.text });
}
}
if (obj.type === "assistant" && obj.message && typeof obj.message === "object") {
const msg = obj.message as { content?: Array<{ type?: string; text?: string; name?: string; input?: unknown }> };
const toolHtml = rescueHtmlFromToolUse(msg.content);
if (toolHtml) {
out.push({ kind: "html", text: toolHtml });
state.sawStreamEventText = true;
}
if (!state.sawStreamEventText) {
const text = (msg.content ?? [])
.filter((c) => c?.type === "text" && typeof c.text === "string")
.map((c) => c.text!)
.join("");
if (text) out.push({ kind: "delta", text });
}
}
if (obj.type === "result") {
if (obj.usage) out.push({ kind: "meta", key: "usage", value: obj.usage });
if (typeof obj.duration_ms === "number") out.push({ kind: "meta", key: "duration_ms", value: obj.duration_ms });
}
if (typeof obj.text === "string" && !state.sawStreamEventText && obj.type !== "assistant") {
out.push({ kind: "delta", text: obj.text });
}
}
return out;
}
function makeParser(agent: string): (line: string) => AgentParse[] {
const state: ParseState = {};
return (line: string) => parseLineWithState(agent, line, state);
}
// ─── resolve OpenClaw agent id ────────────────────────────────────────
let openclawAgentIdCache: { value: string; expiresAt: number } | null = null;
async function resolveOpenclawAgentId(bin: string): Promise<string> {
const now = Date.now();
if (openclawAgentIdCache && openclawAgentIdCache.expiresAt > now) {
return openclawAgentIdCache.value;
}
let resolved = "main";
try {
const { spawn: spawnAsync } = await import("node:child_process");
const out = await new Promise<string>((res, rej) => {
const child = spawnAsync(bin, ["agents", "list"], {
stdio: ["ignore", "pipe", "pipe"],
shell: process.platform === "win32",
});
let buf = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", (c: string) => (buf += c));
child.on("close", () => res(buf));
child.on("error", rej);
setTimeout(() => {
try { child.kill("SIGTERM"); } catch {}
rej(new Error("openclaw agents list timed out"));
}, 5_000);
});
const m = out.match(/^- (\S+)/m);
if (m && m[1]) resolved = m[1];
} catch {}
openclawAgentIdCache = { value: resolved, expiresAt: now + 5 * 60_000 };
return resolved;
}
// ─── main invoke function ─────────────────────────────────────────────
export function invokeAgent(opts: InvokeOpts): ReadableStream<InvokeEvent> {
const def = AGENTS.find((a) => a.id === opts.agent);
if (!def) {
return errorStream(`unknown agent: ${opts.agent}`);
}
const resolved = resolveBinForAgent(def, opts.binOverride);
if (resolved.kind === "override-missing") {
return errorStream(
`${def.label}: custom path \`${resolved.tried}\` does not exist.`,
);
}
if (resolved.kind === "not-found") {
return errorStream(
`${def.label} (\`${def.bin}\`) is not installed or not on PATH.`,
);
}
const bin: string = resolved.bin;
const env = envFor(opts.agent);
const promptViaArgv = def.protocol === "argv";
const promptViaMessageFlag = def.protocol === "argv-message";
return new ReadableStream<InvokeEvent>({
async start(controller) {
let closed = false;
let child: ChildProcessWithoutNullStreams | null = null;
const safeEnqueue = (ev: InvokeEvent) => {
if (closed) return;
try {
controller.enqueue(ev);
} catch {
closed = true;
}
};
const safeClose = () => {
if (closed) return;
closed = true;
try {
controller.close();
} catch {}
};
let argv: string[];
try {
const argvOpts: AgentArgvOpts = {
model: opts.model,
};
if (opts.agent === "openclaw") {
argvOpts.openclawAgentId = await resolveOpenclawAgentId(bin);
}
argv = buildArgv(opts.agent, argvOpts);
} catch (err) {
safeEnqueue({
type: "error",
message:
err instanceof UnsupportedAgentProtocolError
? err.message
: err instanceof Error
? err.message
: String(err),
});
safeClose();
return;
}
if (promptViaArgv) argv = [...argv, opts.prompt];
if (promptViaMessageFlag) argv = [...argv, "--message", opts.prompt];
try {
child = spawn(bin, argv, {
cwd: opts.cwd ?? process.cwd(),
env,
stdio: ["pipe", "pipe", "pipe"],
shell: process.platform === "win32",
});
} catch (err) {
safeEnqueue({
type: "error",
message: err instanceof Error ? err.message : String(err),
});
safeClose();
return;
}
safeEnqueue({
type: "start",
bin,
argv,
promptBytes: Buffer.byteLength(opts.prompt, "utf8"),
});
child.stdin.on("error", () => {});
try {
if (!promptViaArgv && !promptViaMessageFlag) child.stdin.write(opts.prompt);
child.stdin.end();
} catch {}
const parse = makeParser(opts.agent);
let stdoutBuf = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
if (closed) return;
stdoutBuf += chunk;
if (opts.agent === "openclaw") return;
let nl: number;
while ((nl = stdoutBuf.indexOf("\n")) !== -1) {
const line = stdoutBuf.slice(0, nl);
stdoutBuf = stdoutBuf.slice(nl + 1);
if (!line) continue;
for (const part of parse(line)) {
if (part.kind === "delta") safeEnqueue({ type: "delta", text: part.text });
else if (part.kind === "html") safeEnqueue({ type: "html", text: part.text });
else if (part.kind === "meta") safeEnqueue({ type: "meta", key: part.key, value: part.value });
else safeEnqueue({ type: "raw", text: line.slice(0, 240) });
}
}
});
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
safeEnqueue({ type: "stderr", text: chunk });
});
child.on("error", (err) => {
safeEnqueue({ type: "error", message: err.message });
safeClose();
});
child.on("close", (code) => {
if (opts.agent === "openclaw") {
if (stdoutBuf.trim()) {
try {
const obj = JSON.parse(stdoutBuf) as {
payloads?: Array<{ text?: string }>;
meta?: {
finalAssistantVisibleText?: string;
finalAssistantRawText?: string;
executionTrace?: { winnerProvider?: string; winnerModel?: string };
completion?: { stopReason?: string };
agentMeta?: { sessionId?: string };
};
};
const text = obj?.meta?.finalAssistantVisibleText
?? obj?.meta?.finalAssistantRawText
?? obj?.payloads?.[0]?.text
?? "";
if (text) safeEnqueue({ type: "delta", text });
} catch (err) {
safeEnqueue({
type: "error",
message: `OpenClaw JSON parse failed: ${err instanceof Error ? err.message : String(err)}`,
});
}
}
} else if (stdoutBuf) {
if (opts.agent === "aider" || opts.agent === "codewhale" || opts.agent === "deepseek-tui") {
safeEnqueue({ type: "delta", text: stdoutBuf });
} else {
for (const part of parse(stdoutBuf)) {
if (part.kind === "delta") safeEnqueue({ type: "delta", text: part.text });
else if (part.kind === "html") safeEnqueue({ type: "html", text: part.text });
else if (part.kind === "meta") safeEnqueue({ type: "meta", key: part.key, value: part.value });
}
}
}
safeEnqueue({ type: "done", code });
safeClose();
});
const onAbort = () => {
try {
child?.kill("SIGTERM");
} catch {}
safeClose();
};
opts.signal?.addEventListener("abort", onAbort, { once: true });
},
cancel() {},
});
}
function errorStream(message: string): ReadableStream<InvokeEvent> {
return new ReadableStream<InvokeEvent>({
start(controller) {
controller.enqueue({ type: "error", message });
controller.close();
},
});
}
+29
View File
@@ -0,0 +1,29 @@
import path from "node:path";
export function findCommonPath(dirs: string[]): string {
if (dirs.length === 0) return "";
const resolved = dirs.map((d) => path.resolve(d));
const segments = resolved.map((d) => d.split(path.sep).filter(Boolean));
const minLen = Math.min(...segments.map((s) => s.length));
let common = 0;
for (let i = 0; i < minLen; i++) {
const seg = segments[0][i];
if (segments.every((s) => s[i] === seg)) common++;
else break;
}
return segments[0].slice(0, common).join(path.sep) || path.sep;
}
export function resolveCollisionOutput(inputPath: string, outputDir: string, commonRoot: string): string {
const basename = path.basename(inputPath, path.extname(inputPath));
const inputDir = path.resolve(path.dirname(inputPath));
let relativeDir = path.relative(commonRoot, inputDir);
relativeDir = relativeDir
.split(path.sep)
.filter((s) => s !== ".." && s !== ".")
.join(path.sep);
if (relativeDir) {
return path.resolve(outputDir, relativeDir, `${basename}.html`);
}
return path.resolve(outputDir, `${basename}.html`);
}
+45
View File
@@ -0,0 +1,45 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
export interface CliConfig {
defaultTemplate?: string;
defaultAgent?: string;
model?: string;
}
const CONFIG_DIR = path.join(os.homedir(), ".config", "html-anything");
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
function ensureConfigDir(): void {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
}
export function loadConfig(): CliConfig {
try {
if (fs.existsSync(CONFIG_PATH)) {
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
return JSON.parse(raw) as CliConfig;
}
} catch {
}
return {};
}
export function saveConfig(config: CliConfig): void {
ensureConfigDir();
const merged = { ...loadConfig(), ...config };
try {
fs.writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2), "utf-8");
} catch (err) {
throw new Error(
`Cannot write config to ${CONFIG_PATH}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
export function getConfigPath(): string {
return CONFIG_PATH;
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Extract HTML from agent output — adapted from next/src/lib/extract-html.ts
*/
export function extractHtml(streamed: string): string {
if (!streamed) return "";
const fence = streamed.match(/```(?:html|HTML)?\s*([\s\S]*?)```/);
if (fence) {
const inner = fence[1].trim();
if (inner.startsWith("<")) return inner;
}
const doctypeStart = streamed.search(/<!DOCTYPE\s+html/i);
if (doctypeStart !== -1) {
const closeIdx = streamed.lastIndexOf("</html>");
if (closeIdx !== -1) {
return streamed.slice(doctypeStart, closeIdx + "</html>".length);
}
return streamed.slice(doctypeStart);
}
const htmlStart = streamed.search(/<html[\s>]/i);
if (htmlStart !== -1) {
const closeIdx = streamed.lastIndexOf("</html>");
if (closeIdx !== -1) {
return streamed.slice(htmlStart, closeIdx + "</html>".length);
}
return streamed.slice(htmlStart);
}
if (streamed.trimStart().startsWith("<")) {
return streamed;
}
return "";
}
+857
View File
@@ -0,0 +1,857 @@
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import { fileURLToPath } from "node:url";
import { loadSkill, listSkills, type SkillMeta, type LoadedSkill } from "./skills-loader.js";
import { detectAgents, type DetectedAgent } from "./agents-detect.js";
import { assemblePrompt } from "./prompt-assemble.js";
import { invokeAgent, type InvokeEvent } from "./agents-invoke.js";
import { extractHtml } from "./extract-html.js";
import { loadConfig, saveConfig, getConfigPath, type CliConfig } from "./config.js";
import { matchTemplate, type MatchResult } from "./skills-matcher.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function findWorkspaceRoot(): string {
let dir = __dirname;
while (true) {
if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return process.cwd();
}
const WORKSPACE_ROOT = findWorkspaceRoot();
const SKILLS_DIR = path.join(WORKSPACE_ROOT, "next", "src", "lib", "templates", "skills");
export function getSkillsDir(): string {
return SKILLS_DIR;
}
export function getAvailableTemplates(): SkillMeta[] {
return listSkills(SKILLS_DIR);
}
export function getTemplate(id: string): LoadedSkill | null {
return loadSkill(SKILLS_DIR, id);
}
export function getAvailableAgents(): DetectedAgent[] {
return detectAgents();
}
function findAgent(agentId?: string): DetectedAgent | null {
const agents = getAvailableAgents();
if (agentId) {
return agents.find((a) => a.id === agentId && a.available) ?? null;
}
const config = loadConfig();
if (config.defaultAgent) {
const found = agents.find((a) => a.id === config.defaultAgent && a.available && !a.unsupported);
if (found) return found;
}
return agents.find((a) => a.available && !a.unsupported) ?? null;
}
function printHelp(): void {
console.log(`html-anything — AI-powered Markdown to HTML converter (CLI)
USAGE:
html-anything <command> [options]
COMMANDS:
convert [input] Convert Markdown to HTML
input Input file (markdown), or use stdin if omitted
--template, -t <id> Template ID (default: uses saved default)
--agent, -a <id> Agent ID (default: auto-detect)
--output, -o <path> Output file path (default: auto-save to <input>.html or stdout)
--output-dir, -d <dir> Output directory for auto-saved files (default: current dir)
--model <id> Model to use (optional)
--format <type> Input format: markdown, text, csv, json (default: markdown)
auto [input] Auto-detect best template and convert
input Input file, or use stdin if omitted
--agent, -a <id> Agent ID (default: auto-detect)
--output, -o <path> Output file path
--output-dir, -d <dir> Output directory for auto-saved files
--model <id> Model to use (optional)
--format <type> Input format: markdown, text, csv, json (default: markdown)
--force-ai Force AI summary for matching
--show-match-only Show match result, skip conversion
templates List all available templates
agents List detected AI agents
config Show current configuration
config set-default-template <id> Set the default template
config set-default-agent <id> Set the default AI agent
config set-model <id> Set the default model
config reset Reset all configuration
EXAMPLES:
html-anything auto article.md
html-anything auto article.md -o output.html
html-anything auto article.md --show-match-only
html-anything auto article.md --force-ai
cat article.md | html-anything auto
html-anything convert article.md
html-anything convert article.md -t doc-kami-parchment -o output.html
html-anything convert article.md -t doc-kami-parchment -d ./dist
html-anything convert article.md -a claude --model sonnet
html-anything convert file1.md file2.md file3.md -d ./dist
cat article.md | html-anything convert
html-anything config set-default-template resume-modern
html-anything templates
html-anything agents
`);
}
function createSpinner(msg: string) {
if (!process.stderr.isTTY) {
const start = Date.now();
let chunkCount = 0;
return {
tick: () => { chunkCount++; },
start,
stop: (final?: string) => {
if (final !== undefined) process.stderr.write(`${final}\n`);
},
};
}
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let i = 0;
let chunkCount = 0;
const start = Date.now();
let lastLen = 0;
const interval = setInterval(() => {
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
const frame = frames[i % frames.length];
const text = `\r ${frame} ${msg} ${chunkCount} chunks / ${elapsed}s \x1b[90m(Ctrl+C to stop)\x1b[0m`;
process.stderr.write(text);
lastLen = text.length;
i++;
}, 80);
return {
tick: () => { chunkCount++; },
start,
stop: (final?: string) => {
clearInterval(interval);
process.stderr.write("\r" + " ".repeat(lastLen) + "\r");
if (final !== undefined) process.stderr.write(`${final}\n`);
},
};
}
async function handleConvert(args: string[]): Promise<void> {
const flags: Record<string, string> = {};
const positional: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--template" || arg === "-t") {
flags.template = args[++i] ?? "";
} else if (arg === "--agent" || arg === "-a") {
flags.agent = args[++i] ?? "";
} else if (arg === "--output" || arg === "-o") {
flags.output = args[++i] ?? "";
} else if (arg === "--output-dir" || arg === "-d") {
flags.outputDir = args[++i] ?? "";
} else if (arg === "--model") {
flags.model = args[++i] ?? "";
} else if (arg === "--format") {
flags.format = args[++i] ?? "";
} else if (arg === "--help" || arg === "-h") {
printHelp();
return;
} else if (!arg.startsWith("-")) {
positional.push(arg);
} else {
console.error(`Unknown option: ${arg}`);
process.exit(1);
}
}
if (flags.output && flags.outputDir) {
console.error("Error: --output (-o) and --output-dir (-d) cannot be used together.");
process.exit(1);
}
if (flags.output && positional.length > 1) {
console.error("Error: --output (-o) cannot be used with multiple input files. Use --output-dir (-d) instead.");
process.exit(1);
}
const VALID_FORMATS = ["markdown", "text", "csv", "json"];
const format = flags.format ?? "markdown";
if (!VALID_FORMATS.includes(format)) {
console.error(`Error: Unknown format "${format}". Supported: ${VALID_FORMATS.join(", ")}`);
process.exit(1);
}
const config = loadConfig();
const templateId = flags.template ?? config.defaultTemplate;
if (!templateId) {
console.error("Error: No template specified. Use --template <id> or set a default with:");
console.error(" html-anything config set-default-template <id>");
console.error("\nAvailable templates:");
for (const t of getAvailableTemplates()) {
console.error(` ${t.id}${t.zhName}`);
}
process.exit(1);
}
const skill = getTemplate(templateId);
if (!skill) {
console.error(`Error: Unknown template "${templateId}"`);
console.error("Run 'html-anything templates' to list available templates.");
process.exit(1);
}
const agent = findAgent(flags.agent);
if (!agent) {
const wantId = flags.agent ?? config.defaultAgent ?? "(auto-detect)";
console.error(`Error: No available AI agent found${flags.agent ? ` for "${wantId}"` : ""}.`);
console.error("\nDetected agents:");
for (const a of getAvailableAgents()) {
const status = a.available ? (a.unsupported ? "(unsupported)" : "✓") : "✗";
console.error(` ${status} ${a.id}${a.label}`);
}
console.error("\nInstall one of the supported agents (e.g. 'claude', 'codex', 'gemini') and try again.");
process.exit(1);
}
const model = flags.model ?? config.model;
const inputPaths = positional.length > 0 ? positional : [];
if (inputPaths.length === 0) {
const content = await readStdin();
if (!content.trim()) {
console.error("Error: No input provided. Pipe content via stdin or specify an input file.");
process.exit(1);
}
process.stdout.on("error", (err) => {
if ((err as NodeJS.ErrnoException).code === "EPIPE") process.exit(0);
});
const ok = await convertOne({ inputPath: null, content, skill, agent, model, format, flags });
if (!ok) process.exit(1);
} else {
const outputDir = flags.outputDir || process.cwd();
if (inputPaths.length > 1) {
const flatBasenames = inputPaths.map((p) => path.basename(p, path.extname(p)));
const basenameCounts = new Map<string, string[]>();
for (let i = 0; i < flatBasenames.length; i++) {
const key = flatBasenames[i];
if (!basenameCounts.has(key)) basenameCounts.set(key, []);
basenameCounts.get(key)!.push(inputPaths[i]);
}
const collisions = [...basenameCounts].filter(([, paths]) => paths.length > 1);
if (collisions.length > 0) {
console.error(`\x1b[33m⚠\x1b[0m Multiple inputs would produce the same output basename:`);
for (const [basename, paths] of collisions) {
console.error(` ${basename}:`);
for (const p of paths) console.error(`${p}`);
}
const useRelative = await promptYesNo(
"\x1b[33m⚠\x1b[0m Save with relative directory paths (e.g. dir1/readme.html)? (y/N): ",
);
if (!useRelative) {
console.error(
"Aborted. Rename your input files to use different basenames, or use --output (-o) for each file.",
);
process.exit(1);
}
}
const collisionPaths = collisions.flatMap(([, paths]) => paths);
const commonRoot = findCommonPath(collisionPaths);
const outputPlan = inputPaths.map((p) => ({
inputPath: p,
outputPath: collisions.length > 0
? resolveCollisionOutput(p, outputDir, commonRoot)
: path.resolve(outputDir, `${path.basename(p, path.extname(p))}.html`),
}));
const existingFiles = outputPlan.filter((p) => fs.existsSync(p.outputPath));
if (existingFiles.length > 0) {
if (process.stdin.isTTY && process.stderr.isTTY) {
console.error(`\x1b[33m⚠\x1b[0m The following output files already exist:`);
for (const p of existingFiles) console.error(` ${p.outputPath}`);
const ok = await promptYesNo("\x1b[33m⚠\x1b[0m Overwrite? (y/N): ");
if (!ok) {
console.error("Aborted.");
process.exit(1);
}
}
}
let failed = 0;
for (const plan of outputPlan) {
let content: string;
try {
content = fs.readFileSync(plan.inputPath, "utf-8");
} catch (err) {
console.error(`Error: Cannot read "${plan.inputPath}": ${err instanceof Error ? err.message : err}`);
failed++;
continue;
}
const ok = await convertOne({
inputPath: plan.inputPath,
content,
skill,
agent,
model,
format,
flags,
resolvedOutputPath: plan.outputPath,
});
if (!ok) failed++;
}
if (failed > 0) {
console.error(`\n${failed}/${inputPaths.length} file(s) failed.`);
process.exit(1);
}
} else {
let failed = 0;
for (const inputPath of inputPaths) {
let content: string;
try {
content = fs.readFileSync(inputPath, "utf-8");
} catch (err) {
console.error(`Error: Cannot read "${inputPath}": ${err instanceof Error ? err.message : err}`);
failed++;
continue;
}
const ok = await convertOne({ inputPath, content, skill, agent, model, format, flags });
if (!ok) failed++;
}
if (failed > 0) {
console.error(`\n${failed}/${inputPaths.length} file(s) failed.`);
process.exit(1);
}
}
}
}
async function convertOne(opts: {
inputPath: string | null;
content: string;
skill: LoadedSkill;
agent: DetectedAgent;
model: string | undefined;
format: string;
flags: Record<string, string>;
resolvedOutputPath?: string;
}): Promise<boolean> {
const { inputPath, content, skill, agent: selectedAgent, model, format, flags } = opts;
const prompt = assemblePrompt({ body: skill.body, content, format });
const label = inputPath ? path.basename(inputPath) : "stdin";
console.error(`Template: ${skill.zhName} (${skill.id})`);
console.error(`Agent: ${selectedAgent.label} (${selectedAgent.id})`);
if (model) console.error(`Model: ${model}`);
if (inputPath) console.error(`Input: ${label}`);
console.error("");
const stream = invokeAgent({
agent: selectedAgent.id,
prompt,
model,
});
const reader = stream.getReader();
let htmlAccum = "";
const spinner = createSpinner(`Generating HTML for ${label}...`);
let stderrBuf = "";
let exitCode: number | null = null;
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (!value) continue;
switch (value.type) {
case "delta":
htmlAccum += value.text;
spinner.tick();
break;
case "html":
htmlAccum = value.text;
spinner.tick();
break;
case "error":
spinner.stop(`\x1b[31m✗\x1b[0m Error: ${value.message}`);
return false;
case "stderr":
stderrBuf += value.text;
break;
case "done":
exitCode = value.code;
break;
case "meta":
case "raw":
case "start":
break;
}
}
} catch (err) {
spinner.stop(`\x1b[31m✗\x1b[0m Error: ${err instanceof Error ? err.message : String(err)}`);
return false;
}
if (exitCode !== null && exitCode !== 0) {
const elapsed = ((Date.now() - spinner.start) / 1000).toFixed(1);
spinner.stop(`\x1b[31m✗\x1b[0m Agent exited with code ${exitCode} after ${elapsed}s`);
if (stderrBuf.trim()) {
console.error("Agent stderr:", stderrBuf.trim());
}
return false;
}
const elapsed = ((Date.now() - spinner.start) / 1000).toFixed(1);
spinner.stop(`\x1b[32m✓\x1b[0m Done in ${elapsed}s`);
const html = extractHtml(htmlAccum);
if (!html) {
console.error("Error: Agent did not produce valid HTML output.");
if (htmlAccum) console.error("Raw output:\n", htmlAccum.slice(0, 500));
return false;
}
let outputPath: string | undefined;
if (opts.resolvedOutputPath) {
outputPath = opts.resolvedOutputPath;
} else if (flags.output) {
outputPath = path.resolve(flags.output);
} else if (inputPath) {
const basename = path.basename(inputPath, path.extname(inputPath));
const outputDir = flags.outputDir || process.cwd();
outputPath = path.resolve(outputDir, `${basename}.html`);
}
if (outputPath) {
if (!opts.resolvedOutputPath && fs.existsSync(outputPath)) {
const overwrite = await promptOverwrite(outputPath);
if (!overwrite) {
console.error(`Skipped: ${outputPath}`);
return true;
}
}
try {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, html, "utf-8");
console.error(`Saved to: ${outputPath}`);
return true;
} catch (err) {
console.error(`Error: Cannot write to "${outputPath}": ${err instanceof Error ? err.message : err}`);
return false;
}
} else {
process.stdout.write(html);
return true;
}
}
function findCommonPath(dirs: string[]): string {
if (dirs.length === 0) return "";
const resolved = dirs.map((d) => path.resolve(d));
const segments = resolved.map((d) => d.split(path.sep).filter(Boolean));
const minLen = Math.min(...segments.map((s) => s.length));
let common = 0;
for (let i = 0; i < minLen; i++) {
const seg = segments[0][i];
if (segments.every((s) => s[i] === seg)) common++;
else break;
}
return segments[0].slice(0, common).join(path.sep) || path.sep;
}
function resolveCollisionOutput(inputPath: string, outputDir: string, commonRoot: string): string {
const basename = path.basename(inputPath, path.extname(inputPath));
const inputDir = path.resolve(path.dirname(inputPath));
let relativeDir = path.relative(commonRoot, inputDir);
relativeDir = relativeDir
.split(path.sep)
.filter((s) => s !== ".." && s !== ".")
.join(path.sep);
if (relativeDir) {
return path.resolve(outputDir, relativeDir, `${basename}.html`);
}
return path.resolve(outputDir, `${basename}.html`);
}
function promptYesNo(question: string): Promise<boolean> {
return new Promise((resolve) => {
if (!process.stdin.isTTY || !process.stderr.isTTY) {
resolve(false);
return;
}
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() === "y");
});
});
}
function promptOverwrite(filepath: string): Promise<boolean> {
return new Promise((resolve) => {
if (!process.stdin.isTTY || !process.stderr.isTTY) {
resolve(true);
return;
}
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
rl.question(`\x1b[33m⚠\x1b[0m ${filepath} already exists. Overwrite? (y/N): `, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() === "y");
});
});
}
function readStdin(): Promise<string> {
return new Promise((resolve) => {
if (process.stdin.isTTY) {
resolve("");
return;
}
const chunks: Buffer[] = [];
process.stdin.on("data", (chunk: Buffer) => chunks.push(chunk));
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
process.stdin.on("error", () => resolve(""));
});
}
async function handleAuto(args: string[]): Promise<void> {
const flags: Record<string, string> = {};
let forceAi = false;
let showMatchOnly = false;
const positional: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--force-ai") {
forceAi = true;
} else if (arg === "--show-match-only") {
showMatchOnly = true;
} else if (arg === "--agent" || arg === "-a") {
flags.agent = args[++i] ?? "";
} else if (arg === "--output" || arg === "-o") {
flags.output = args[++i] ?? "";
} else if (arg === "--output-dir" || arg === "-d") {
flags.outputDir = args[++i] ?? "";
} else if (arg === "--model") {
flags.model = args[++i] ?? "";
} else if (arg === "--format") {
flags.format = args[++i] ?? "";
} else if (arg === "--help" || arg === "-h") {
printHelp();
return;
} else if (!arg.startsWith("-")) {
positional.push(arg);
} else {
console.error(`Unknown option: ${arg}`);
process.exit(1);
}
}
if (flags.output && flags.outputDir) {
console.error("Error: --output (-o) and --output-dir (-d) cannot be used together.");
process.exit(1);
}
if (showMatchOnly && flags.output) {
console.error("Error: --show-match-only cannot be used with --output (-o).");
process.exit(1);
}
const VALID_FORMATS = ["markdown", "text", "csv", "json"];
const format = flags.format ?? "markdown";
if (!VALID_FORMATS.includes(format)) {
console.error(`Error: Unknown format "${format}". Supported: ${VALID_FORMATS.join(", ")}`);
process.exit(1);
}
const config = loadConfig();
const agent = findAgent(flags.agent);
if (!agent) {
const wantId = flags.agent ?? config.defaultAgent ?? "(auto-detect)";
console.error(`Error: No available AI agent found${flags.agent ? ` for "${wantId}"` : ""}.`);
console.error("\nDetected agents:");
for (const a of getAvailableAgents()) {
const status = a.available ? (a.unsupported ? "(unsupported)" : "✓") : "✗";
console.error(` ${status} ${a.id}${a.label}`);
}
console.error("\nInstall one of the supported agents (e.g. 'claude', 'codex', 'gemini') and try again.");
process.exit(1);
}
const model = flags.model ?? config.model;
const inputPath = positional.length > 0 ? positional[0] : null;
let content: string;
if (inputPath) {
try {
content = fs.readFileSync(inputPath, "utf-8");
} catch (err) {
console.error(`Error: Cannot read "${inputPath}": ${err instanceof Error ? err.message : err}`);
process.exit(1);
}
} else {
content = await readStdin();
if (!content.trim()) {
console.error("Error: No input provided. Pipe content via stdin or specify an input file.");
process.exit(1);
}
process.stdout.on("error", (err) => {
if ((err as NodeJS.ErrnoException).code === "EPIPE") process.exit(0);
});
}
const templates = getAvailableTemplates();
if (templates.length === 0) {
console.error("Error: No templates found.");
process.exit(1);
}
const label = inputPath ? path.basename(inputPath) : "stdin";
console.error(`Matching template for: ${label}`);
console.error(`Agent: ${agent.label} (${agent.id})`);
if (model) console.error(`Model: ${model}`);
console.error("");
const result = await matchTemplate(
content,
templates,
SKILLS_DIR,
agent.id,
forceAi,
);
console.error(`Matched: ${result.zhName} (${result.templateId})`);
console.error(`Confidence: ${result.confidence}/10`);
console.error(`Reason: ${result.reason}`);
if (showMatchOnly) return;
console.error("");
const skill = getTemplate(result.templateId);
if (!skill) {
console.error(`Error: Unknown template "${result.templateId}"`);
process.exit(1);
}
const ok = await convertOne({ inputPath, content, skill, agent, model, format, flags });
if (!ok) process.exit(1);
}
function handleTemplates(): void {
const templates = getAvailableTemplates();
if (templates.length === 0) {
console.log("No templates found.");
return;
}
const config = loadConfig();
console.log(`Available templates (${templates.length}):\n`);
const byCategory: Record<string, SkillMeta[]> = {};
for (const t of templates) {
const cat = t.category || "other";
if (!byCategory[cat]) byCategory[cat] = [];
byCategory[cat].push(t);
}
for (const [category, skills] of Object.entries(byCategory)) {
console.log(`[${category}]`);
for (const s of skills) {
const isDefault = s.id === config.defaultTemplate ? " (default)" : "";
console.log(` ${s.emoji} ${s.id}${s.zhName}${isDefault}`);
}
console.log();
}
}
function handleAgents(): void {
const agents = getAvailableAgents();
const config = loadConfig();
if (agents.length === 0) {
console.log("No agents detected.");
return;
}
console.log("Detected AI agents:\n");
for (const a of agents) {
const status = a.available
? a.unsupported
? "⚠ (unsupported)"
: "✓"
: "✗";
const isDefault = a.id === config.defaultAgent ? " (default)" : "";
console.log(` ${status} ${a.id}${a.label} (${a.vendor})${isDefault}`);
}
}
function handleConfig(args: string[]): void {
if (args.length === 0) {
const config = loadConfig();
console.log("Current configuration:");
if (Object.keys(config).length === 0) {
console.log(" (no configuration set)");
} else {
if (config.defaultTemplate) {
const t = getTemplate(config.defaultTemplate);
console.log(` default-template: ${config.defaultTemplate}${t ? ` (${t.zhName})` : ""}`);
}
if (config.defaultAgent) console.log(` default-agent: ${config.defaultAgent}`);
if (config.model) console.log(` model: ${config.model}`);
}
console.log(`\nConfig file: ${getConfigPath()}`);
return;
}
const sub = args[0];
const val = args[1];
switch (sub) {
case "set-default-template": {
if (!val) {
console.error("Error: Specify a template ID.");
process.exit(1);
}
const skill = getTemplate(val);
if (!skill) {
console.error(`Error: Unknown template "${val}"`);
process.exit(1);
}
try {
saveConfig({ defaultTemplate: val });
console.log(`Default template set to: ${val} (${skill.zhName})`);
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
break;
}
case "set-default-agent": {
if (!val) {
console.error("Error: Specify an agent ID.");
process.exit(1);
}
const agents = getAvailableAgents();
const agent = agents.find((a) => a.id === val);
if (!agent) {
console.error(`Error: Unknown agent "${val}"`);
process.exit(1);
}
if (!agent.available) {
console.error(`Error: Agent "${val}" (${agent.label}) is not installed.`);
process.exit(1);
}
if (agent.unsupported) {
console.error(
`Error: Agent "${val}" (${agent.label}) uses an unsupported protocol.`,
);
console.error("Available supported agents:");
for (const a of agents.filter((a) => a.available && !a.unsupported)) {
console.error(` ${a.id}${a.label}`);
}
process.exit(1);
}
try {
saveConfig({ defaultAgent: val });
console.log(`Default agent set to: ${val} (${agent.label})`);
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
break;
}
case "set-model": {
if (!val) {
console.error("Error: Specify a model ID.");
process.exit(1);
}
try {
saveConfig({ model: val });
console.log(`Default model set to: ${val}`);
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
break;
}
case "reset": {
try {
saveConfig({ defaultTemplate: undefined, defaultAgent: undefined, model: undefined });
console.log("Configuration reset.");
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
break;
}
default:
console.error(`Unknown config command: ${sub}`);
console.error("Available: set-default-template, set-default-agent, set-model, reset");
process.exit(1);
}
}
export async function main(args: string[]): Promise<void> {
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
printHelp();
return;
}
const command = args[0];
const rest = args.slice(1);
switch (command) {
case "convert":
await handleConvert(rest);
break;
case "auto":
await handleAuto(rest);
break;
case "templates":
handleTemplates();
break;
case "agents":
handleAgents();
break;
case "config":
handleConfig(rest);
break;
case "--version":
case "-v":
console.log("html-anything CLI v0.1.0");
break;
default:
console.error(`Unknown command: ${command}`);
console.error("Run 'html-anything --help' for usage information.");
process.exit(1);
}
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Prompt assembly — adapted from next/src/lib/templates/shared.ts
*/
const SHARED_DESIGN_DIRECTIVES = `
你是世界级的视觉设计师 + 资深前端工程师。请输出一份**自包含的单文件 HTML**,要求:
【内容驱动数量 — 最高优先级, 覆盖模板里的任何数字】
- 模板只定义"可用版面 / 风格 / 配色 / 字体 / 组件库", **不定义** slide / 帧 / 卡片 / section 的数量。
- 输出的 slide / frame / card / section 数量**完全由【用户内容】的实际长度和信息结构决定**。必须**完整覆盖**用户内容的每一个要点、章节、数据组, **不许总结、压缩、丢弃信息**。
- 如果模板正文里写了类似"挑 6-10 张组成 deck / 输出 6-10 帧 / 3-6 张卡片"的数字, **一律视为短示例下的参考下限, 不是上限**。短内容可以低于该范围, 长内容应远超该范围 — 用户给了 12k 字符的内容, 输出 4-6 张是**严重错误**。
- 模板里的"N 个锁死版面 / N 个磁带式版面 / N 个 layout"指的是**可复用的版式池**, 同一个版式允许在不同内容上多次出现 (例如 KPI Tower 可以连续用 3 次承载不同章节的数据), 不是页数上限。
- 推荐做法: 先把【用户内容】按语义切成若干段 (章节标题 / 论点 / 数据组 / 列表项 / 步骤), 每一段 → 至少一个独立的 slide / section / card, 然后再从模板的版式池里给每一段挑最合适的版面。宁可多页也不要把多个独立要点硬塞进一页。
【硬性技术要求】
- **禁止使用 Write / Edit / MultiEdit / Bash / Create / 任何文件系统工具**。不要把 HTML 写到任何 \`.html\` 文件里。前端直接捕获你的 stdout 文本, 文件落盘由前端负责。
- 直接把完整的 HTML 文档作为助手回复的正文流式输出。不要先说"我来生成"、"已输出至 …"之类的话。
- 文档以 \`<!DOCTYPE html>\` 开头, 末尾以 \`</html>\` 结束。
- 在 \`<head>\` 中通过 CDN 引入 Tailwind v3 Play (https://cdn.tailwindcss.com) 与所需的 Google Fonts。
- 不要引用任何外部图片 URL(除非你能保证 URL 长期有效;优先使用 CSS / SVG 内联绘制)。
- 必要的脚本(图表、动画)通过 jsdelivr CDN 引入;保持单文件可双击打开即用。
- 输出**纯 HTML**, 不要用 markdown 代码围栏包裹, 不要任何解释性文字。第一个字符必须是 \`<\`
【设计准则 — 世界级标准】
- 排版: 中文优先 \`Noto Sans SC\` / \`Noto Serif SC\`, 英文 \`Inter\` / \`Manrope\` / \`SF Pro\` 风格。
- 色彩: 使用 1 个主色 + 2 个中性色 + 至多 1 个强调色; 大胆留白; 不使用纯黑纯白 (#000/#fff), 改用 \`#0a0a0a\` / \`#fafafa\`
- 网格: 8 px 基线; 段落最大宽度 65 ch; 标题与正文有清晰的层级。
- 微观细节: 圆角统一 (rounded-xl/2xl), 投影柔和 (shadow-sm/lg), 边框 1px \`#e5e7eb\` / \`#262626\`
- 动效: 仅在必要处使用 \`transition-all\` 或入场 fade-in; 不要喧宾夺主。
- 无障碍: 颜色对比度 ≥ 4.5; 重要交互有 focus 态。
【内容真实性】
- **必须使用用户提供的真实数据**, 不要编造、不要 lorem ipsum、不要 "Your text here"。
- 如果用户数据是结构化数据 (CSV/JSON), 请提取关键洞察并以图表/表格呈现。
- 中文与英文混排时, 中英文之间留半角空格 (盘古之白)。
`;
export function assemblePrompt(opts: {
body: string;
content: string;
format: string;
}): string {
return `${SHARED_DESIGN_DIRECTIVES}
${opts.body.trim()}
【输入格式】: ${opts.format}
【用户内容】:
${opts.content}
`;
}
+29
View File
@@ -0,0 +1,29 @@
import readline from "node:readline";
export function promptYesNo(question: string): Promise<boolean> {
return new Promise((resolve) => {
if (!process.stdin.isTTY || !process.stderr.isTTY) {
resolve(false);
return;
}
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() === "y");
});
});
}
export function promptOverwrite(filepath: string): Promise<boolean> {
return new Promise((resolve) => {
if (!process.stdin.isTTY || !process.stderr.isTTY) {
resolve(true);
return;
}
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
rl.question(`\x1b[33m⚠\x1b[0m ${filepath} already exists. Overwrite? (y/N): `, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() === "y");
});
});
}
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env node
import { main } from "./index.js";
main(process.argv.slice(2));
+201
View File
@@ -0,0 +1,201 @@
import fs from "node:fs";
import path from "node:path";
/**
* File-based skill loader — adapted from next/src/lib/templates/loader.ts
* This version accepts a skillsDir parameter instead of hardcoding process.cwd().
*/
export type SkillFrontmatter = {
name?: string;
zh_name?: string;
en_name?: string;
emoji?: string;
description?: string;
category?: string;
scenario?: string;
aspect_hint?: string;
featured?: number;
recommended?: number;
tags?: string[];
example_id?: string;
example_name?: string;
example_format?: string;
example_tagline?: string;
example_desc?: string;
example_source_url?: string;
example_source_label?: string;
};
export type SkillExampleMeta = {
id: string;
name: string;
format: string;
tagline: string;
desc: string;
source?: { url: string; label: string };
hasHtml: boolean;
hasMd: boolean;
};
export type SkillMeta = {
id: string;
zhName: string;
enName: string;
emoji: string;
description: string;
category: string;
scenario: string;
aspectHint: string;
featured?: number;
recommended?: number;
tags: string[];
example?: SkillExampleMeta;
};
export type LoadedSkill = SkillMeta & {
body: string;
exampleMd?: string;
exampleHtml?: string;
};
function parseFrontmatter(raw: string): { fm: SkillFrontmatter; body: string } {
const m = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?([\s\S]*)$/m.exec(raw);
if (!m) return { fm: {}, body: raw };
const block = m[1];
const body = m[2] ?? "";
const fm: SkillFrontmatter = {};
for (const line of block.split(/\r?\n/)) {
const mm = /^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/.exec(line);
if (!mm) continue;
const key = mm[1];
let val: string = mm[2].trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1).replace(/\\"/g, '"');
}
switch (key) {
case "featured": {
const n = Number(val);
if (Number.isFinite(n)) fm.featured = n;
break;
}
case "recommended": {
const n = Number(val);
if (Number.isFinite(n)) fm.recommended = n;
break;
}
case "tags": {
const arr = /^\[(.*)\]$/.exec(val);
if (arr) {
fm.tags = arr[1]
.split(",")
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
.map((s) => s.replace(/\\"/g, '"'))
.filter(Boolean);
}
break;
}
case "name":
case "zh_name":
case "en_name":
case "emoji":
case "description":
case "category":
case "scenario":
case "aspect_hint":
case "example_id":
case "example_name":
case "example_format":
case "example_tagline":
case "example_desc":
case "example_source_url":
case "example_source_label":
(fm as Record<string, string>)[key] = val;
break;
}
}
return { fm, body: body.trim() };
}
function safeRead(p: string): string | undefined {
try {
return fs.readFileSync(p, "utf8");
} catch {
return undefined;
}
}
function fmToMeta(id: string, fm: SkillFrontmatter, hasHtml: boolean, hasMd: boolean): SkillMeta {
const meta: SkillMeta = {
id,
zhName: fm.zh_name ?? fm.name ?? id,
enName: fm.en_name ?? id,
emoji: fm.emoji ?? "✨",
description: fm.description ?? "",
category: fm.category ?? "other",
scenario: fm.scenario ?? "marketing",
aspectHint: fm.aspect_hint ?? "",
tags: Array.isArray(fm.tags) ? fm.tags : [],
};
if (typeof fm.featured === "number") meta.featured = fm.featured;
if (typeof fm.recommended === "number") meta.recommended = fm.recommended;
if (fm.example_id || hasMd || hasHtml) {
meta.example = {
id: fm.example_id ?? `example-${id}`,
name: fm.example_name ?? `${meta.zhName} 示例`,
format: fm.example_format ?? "markdown",
tagline: fm.example_tagline ?? "",
desc: fm.example_desc ?? "",
hasHtml,
hasMd,
...(fm.example_source_url
? {
source: {
url: fm.example_source_url,
label: fm.example_source_label ?? fm.example_source_url,
},
}
: {}),
};
}
return meta;
}
function isValidId(id: string): boolean {
return /^[a-z0-9][a-z0-9-]*$/i.test(id);
}
export function listSkills(skillsDir: string): SkillMeta[] {
const out: SkillMeta[] = [];
let dirents: fs.Dirent[] = [];
try {
dirents = fs.readdirSync(skillsDir, { withFileTypes: true });
} catch {
return out;
}
for (const ent of dirents) {
if (!ent.isDirectory()) continue;
const id = ent.name;
if (!isValidId(id)) continue;
const dir = path.join(skillsDir, id);
const raw = safeRead(path.join(dir, "SKILL.md"));
if (!raw) continue;
const { fm } = parseFrontmatter(raw);
const hasHtml = fs.existsSync(path.join(dir, "example.html"));
const hasMd = fs.existsSync(path.join(dir, "example.md"));
out.push(fmToMeta(id, fm, hasHtml, hasMd));
}
return out;
}
export function loadSkill(skillsDir: string, id: string): LoadedSkill | null {
if (!isValidId(id)) return null;
const dir = path.join(skillsDir, id);
const raw = safeRead(path.join(dir, "SKILL.md"));
if (!raw) return null;
const { fm, body } = parseFrontmatter(raw);
const exampleMd = safeRead(path.join(dir, "example.md"));
const exampleHtml = safeRead(path.join(dir, "example.html"));
const meta = fmToMeta(id, fm, !!exampleHtml, !!exampleMd);
return { ...meta, body, exampleMd, exampleHtml };
}
+288
View File
@@ -0,0 +1,288 @@
import { loadSkill, type SkillMeta } from "./skills-loader.js";
import { invokeAgent } from "./agents-invoke.js";
const CONFIDENCE_THRESHOLD = 3;
const MIN_CONTENT_LENGTH_FOR_AI = 60;
const STRONG_SIGNALS: [keywords: string[], templateId: string][] = [
[["简历", "resume", "CV", "求职", "工作经历", "教育背景"], "resume-modern"],
[["定价", "pricing", "价格方案", "套餐", "plans", "免费版", "专业版"], "pricing-page"],
[["OKR", "KR", "关键结果", "objectives", "季度目标", "key results"], "team-okrs"],
[["PRD", "产品需求", "spec", "user stories", "用户故事", "需求文档", "功能规格"], "pm-spec"],
[["周报", "weekly", "本周", "下周计划", "本周完成"], "weekly-update"],
[["Runbook", "runbook", "oncall", "on-call", "SRE", "告警", "运维", "事故", "incident"], "eng-runbook"],
[["发票", "invoice", "账单", "税率", "tax", "收款", "付款方"], "invoice"],
[["入职", "onboarding", "新人", "onboard", "欢迎", "新员工"], "hr-onboarding"],
[["SaaS", "landing", "落地页", "产品落地", "hero", "social proof"], "saas-landing"],
[["等候", "waitlist", "预发布", "coming soon", "即将上线", "预约"], "waitlist-page"],
[["会议纪要", "meeting", "notes", "会议记录", "参会人", "action items"], "meeting-notes"],
[["看板", "kanban", "board"], "kanban-board"],
[["Dashboard", "dashboard", "仪表板", "仪表盘", "KPI", "指标", "监控"], "dashboard"],
[["日报", "社媒", "社交媒体", "social media", "粉丝", "follower"], "social-media-dashboard"],
[["博客", "blog", "文章", "长文", "杂志", "magazine", "公众号", "newsletter", "essay", "写作"], "article-magazine"],
[["blog", "post", "技术博客", "blog post", "写作"], "blog-post"],
[["文档", "docs", "API文档", "技术文档", "documentation", "doc"], "docs-page"],
[["小红书", "xiaohongshu", "红书"], "card-xiaohongshu"],
[["推特", "twitter", "tweet", "推文", "𝕏"], "social-x-post-card"],
[["Spotify", "spotify", "正在播放", "now playing", "音乐", "专辑"], "social-spotify-card"],
[["Reddit", "reddit", "投票", "upvote"], "social-reddit-card"],
[["Instagram", "linkedin", "carousel", "轮播", "三联", "thread"], "social-carousel"],
[["海报", "poster", "宣传", "海报设计", "营销海报", "视觉冲击"], "poster-hero"],
[["财报", "finance report", "财务报表", "年报", "季度财报", "利润表", "资产负债表"], "finance-report"],
[["数据报告", "data report", "数据分析", "可视化", "图表", "chart", "statistics"], "data-report"],
[["直播", "live", "弹幕", "chat", "实时数据"], "live-dashboard"],
[["PPT", "幻灯片", "deck", "slides", "presentation", "演讲", "演示", "keynote"], "deck-swiss-international"],
[["原型", "prototype", "线框", "wireframe", "mockup", "低保真", "sketch", "手绘"], "prototype-web"],
[["视频", "video", "帧动画", "hyperframes", "remotion", "mp4", "片头"], "video-hyperframes"],
[["VFX", "特效", "光标", "cursor", "chromatic", "reveal"], "vfx-text-cursor"],
[["恋爱", "dating", "交友", "匹配", "约会", "profile"], "dating-web"],
[["App", "mobile", "手机", "H5", "移动端", "小程序"], "mobile-app"],
[["课程", "course", "模块", "module", "教学", "教程", "课时"], "deck-course-module"],
[["team dashboard", "flow", "工单", "ticket", "flowai"], "flowai-team-dashboard"],
[["eguide", "电子指南", "guide", "手册", "指南"], "digital-eguide"],
[["羊皮纸", "kami", "parchment", "古风", "东方", "传统"], "doc-kami-parchment"],
[["email", "营销邮件", "邮件", "EDM", "newsletter email"], "email-marketing"],
[["苹果", "Apple", "iOS", "soft", "squircle"], "web-proto-soft"],
[["brutalist", "工业", "粗野", "swiss", "industrial print"], "web-proto-brutalist"],
[["editorial", "编辑", "杂志风", "serif", "排版"], "web-proto-editorial"],
[["gamified", "游戏化", "成就", "徽章", "badge", "积分"], "gamified-app"],
[["macOS", "notification", "通知", "桌面通知", "弹窗"], "frame-macos-notification"],
[["glitch", "故障", "cyber", "赛博", "cyberpunk"], "deck-hermes-cyber"],
[["glitch", "title", "故障标题"], "frame-glitch-title"],
[["liquid", "流体", "blob", "渐变背景", "hero"], "frame-liquid-bg-hero"],
[["logo", "outro", "片尾", "结尾", "brand"], "frame-logo-outro"],
[["light leak", "cinema", "电影感", "漏光", "胶片"], "frame-light-leak-cinema"],
[["flowchart", "流程图", "sticky", "便利贴", "流程"], "frame-flowchart-sticky"],
[["chart", "NYT", "数据图", "data chart", "新闻图表", "infographic"], "frame-data-chart-nyt"],
[["3D", "3d", "mockup", "device", "iPhone", "MacBook", "立体"], "mockup-device-3d"],
[["pixel", "8-bit", "像素", "复古", "游戏"], "sprite-animation"],
[["motion", "动效", "循环", "动画", "CSS动画"], "motion-frames"],
[["pitch", "融资", "pitch deck", "路演", "投资人", "BP"], "deck-pitch"],
[["product", "launch", "产品发布", "product launch", "上线"], "deck-product-launch"],
[["simple", "deck", "简单", "简洁", "简约"], "deck-simple"],
[["swiss", "international", "瑞士", "国际", "现代主义"], "deck-swiss-international"],
[["graphify", "graph", "dark", "暗色", "图表", "可视化"], "deck-graphify-dark"],
[["obsidian", "黑曜石", "笔记", "知识"], "deck-obsidian-claude"],
[["guizang", "贵赞", "墨水", "ink", "editorial deck"], "deck-guizang-editorial"],
[["magazine", "web", "杂志网页"], "deck-magazine-web"],
[["safety", "alert", "安全", "告警", "warning"], "deck-safety-alert"],
[["blueprint", "蓝图", "工程"], "deck-blueprint"],
[["replit", "terminal", "终端", "暗色"], "deck-replit"],
[["keyboard", "nav", "键盘导航"], "deck-dir-key-nav"],
[["open slide", "canvas", "画布", "自由"], "deck-open-slide-canvas"],
[["presenter", "演讲者", "演讲模式"], "deck-presenter-mode"],
[["tech", "sharing", "技术分享", "tech talk"], "deck-tech-sharing"],
[["xhs", "xiaohongshu deck"], "deck-xhs-post"],
[["XHS", "pastel", "粉彩", "柔和", "粉色"], "deck-xhs-pastel"],
[["XHS", "white", "白色", "纯净", "极简红书"], "deck-xhs-white"],
[["mobile", "onboarding", "引导", "启动"], "mobile-onboarding"],
[["magazine", "poster", "杂志海报", "杂志风海报"], "magazine-poster"],
];
const SCENARIO_SIGNALS: Record<string, string[]> = {
marketing: ["marketing", "推广", "营销", "广告", "品牌", "brand", "campaign", "landing page", "着陆页", "落地", "转换", "conversion"],
engineering: ["代码", "code", "编程", "engineering", "工程", "开发", "部署", "deploy", "CI/CD", "git", "架构", "arch", "SRE", "devops", "运维", "oncall"],
operations: ["运营", "operation", "ops", "周报", "每周", "weekly", "standup", "站会", "流程", "process", "复盘", "retro"],
product: ["产品", "product", "PM", "PRD", "需求", "requirement", "feature", "功能", "spec", "roadmap", "路线图", "OKR", "目标", "用户故事", "user story"],
design: ["设计", "design", "UI", "UX", "原型", "prototype", "mockup", "wireframe", "线框", "配色", "字体", "typography"],
finance: ["财务", "finance", "财报", "利润", "revenue", "收入", "支出", "expense", "账单", "发票", "invoice", "tax", "税率"],
sales: ["销售", "sales", "定价", "pricing", "plan", "套餐", "订阅", "subscription", "折扣", "discount"],
hr: ["HR", "人事", "招聘", "hire", "入职", "onboard", "简历", "resume", "CV", "面试", "interview", "员工", "employee"],
personal: ["个人", "personal", "简历", "resume", "CV", "spotify", "音乐", "music"],
education: ["教育", "education", "课程", "course", "教程", "tutorial", "学习", "learn", "教学"],
creator: ["创作者", "creator", "内容创作", "社媒", "social media", "粉丝", "follower", "自媒体", "KOL"],
video: ["视频", "video", "帧", "frame", "hyperframes", "remotion", "动画", "animation", "VFX", "片头", "intro"],
};
export interface MatchResult {
templateId: string;
zhName: string;
confidence: number;
reason: string;
}
function normalize(text: string): string {
return text.toLowerCase().replace(/[,。;;!?、\s]+/g, " ").trim();
}
function isAscii(s: string): boolean {
return /^[\x00-\x7F]+$/.test(s);
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function kwMatches(haystack: string, needle: string): boolean {
const kw = needle.toLowerCase();
if (isAscii(kw)) {
return new RegExp("\\b" + escapeRegExp(kw) + "\\b", "i").test(haystack);
}
return haystack.includes(kw);
}
function ruleMatch(content: string, meta: SkillMeta): number {
const lower = normalize(content);
let score = 0;
for (const tag of meta.tags) {
if (kwMatches(lower, tag)) score += 3;
}
const nameWords = normalize(meta.zhName).split(" ").filter((w) => w.length >= 2);
const descWords = normalize(meta.description).split(" ").filter((w) => w.length >= 3);
for (const w of nameWords) {
if (lower.includes(w)) score += 2;
}
for (const w of descWords) {
if (lower.includes(w)) score += 1;
}
const scenarioKeywords = SCENARIO_SIGNALS[meta.scenario] ?? [];
for (const kw of scenarioKeywords) {
if (kwMatches(lower, kw)) score += 1;
}
return score;
}
function strongSignalMatch(
content: string,
skillsDir: string,
): MatchResult | null {
let best: { templateId: string; confidence: number; matched: string[] } | null = null;
const lower = normalize(content);
for (const [keywords, templateId] of STRONG_SIGNALS) {
const matched = keywords.filter((kw) => kwMatches(lower, kw));
if (matched.length > 0) {
const confidence = matched.length * 2 + 3;
if (!best || confidence > best.confidence) {
best = { templateId, confidence, matched };
}
}
}
if (best && best.confidence >= CONFIDENCE_THRESHOLD) {
const meta = loadSkill(skillsDir, best.templateId);
const name = meta?.zhName ?? best.templateId;
return {
templateId: best.templateId,
zhName: name,
confidence: best.confidence,
reason: `关键词命中: ${best.matched.join(", ")}${name}`,
};
}
return null;
}
function fallbackMatch(content: string, templates: SkillMeta[]): MatchResult | null {
let best: SkillMeta | null = null;
let bestScore = 0;
for (const t of templates) {
const score = ruleMatch(content, t);
if (score > bestScore) {
bestScore = score;
best = t;
}
}
if (!best || bestScore < 1) return null;
return {
templateId: best.id,
zhName: best.zhName,
confidence: bestScore,
reason: `最佳匹配: ${best.zhName} (scenario: ${best.scenario}, score: ${bestScore})`,
};
}
async function aiSummaryMatch(
content: string,
templates: SkillMeta[],
agentId: string,
): Promise<MatchResult | null> {
let summary = "";
try {
const prompt = `请用一句话(不超过15个字)描述以下内容最像什么类型的文档,只需回答类型名称:
${content.slice(0, 800)}
类型名称:`;
const stream = invokeAgent({ agent: agentId, prompt });
const reader = stream.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value?.type === "delta") summary += value.text;
if (value?.type === "html") summary = value.text;
if (value?.type === "error") return null;
}
} catch {
return null;
}
const clean = normalize(summary).trim();
if (!clean || clean.length > 100) return null;
return fallbackMatch(clean, templates);
}
export async function matchTemplate(
content: string,
templates: SkillMeta[],
skillsDir: string,
agentId: string,
forceAi: boolean = false,
): Promise<MatchResult> {
if (!forceAi) {
const strong = strongSignalMatch(content, skillsDir);
if (strong) {
strong.confidence = Math.min(strong.confidence, 10);
return strong;
}
}
const rule = fallbackMatch(content, templates);
if (!forceAi && rule && rule.confidence >= CONFIDENCE_THRESHOLD) {
rule.confidence = Math.min(rule.confidence, 10);
return rule;
}
if (content.length < MIN_CONTENT_LENGTH_FOR_AI) {
if (rule) {
rule.confidence = Math.min(rule.confidence, 10);
return rule;
}
const fallback = templates.find((t) => t.id === "deck-swiss-international") ?? templates[0];
return {
templateId: fallback.id,
zhName: fallback.zhName,
confidence: 1,
reason: "内容较短,使用通用模板",
};
}
const ai = await aiSummaryMatch(content, templates, agentId);
if (ai) {
ai.confidence = Math.min(ai.confidence, 10);
return ai;
}
if (rule) {
rule.confidence = Math.min(rule.confidence, 10);
return rule;
}
const fallback = templates.find((t) => t.id === "deck-swiss-international") ?? templates[0];
return {
templateId: fallback.id,
zhName: fallback.zhName,
confidence: 1,
reason: "无法确定主题,使用默认模板",
};
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+541
View File
@@ -0,0 +1,541 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>HTML Anything · Cover</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;0,9..144,700;1,9..144,500;1,9..144,600;1,9..144,700&family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
:root {
--bg: #14100c;
--bg-soft: #1c1712;
--ink: #efe7d6;
--ink-mute: #b9ad97;
--ink-faint: #6f6555;
--rule: #2a2219;
--accent: #e87d3e;
--accent-soft: #d96b2c;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
overflow: hidden;
}
.cover {
width: 1920px;
height: 1080px;
flex: 0 0 auto;
transform: scale(min(calc(100vw / 1920), calc(100vh / 1080)));
transform-origin: center center;
position: relative;
background:
radial-gradient(1100px 700px at 78% 38%, rgba(232,125,62,0.10) 0%, rgba(232,125,62,0.02) 40%, transparent 70%),
radial-gradient(900px 600px at 12% 80%, rgba(232,125,62,0.05) 0%, transparent 60%),
linear-gradient(180deg, #181208 0%, #14100c 45%, #100b07 100%);
overflow: hidden;
isolation: isolate;
}
/* faint grid texture */
.cover::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, rgba(255,236,200,0.025) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,236,200,0.025) 1px, transparent 1px);
background-size: 80px 80px;
pointer-events: none;
z-index: 0;
}
/* grain / film noise */
.cover::after {
content: "";
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 0.95 0 0 0 0 0.85 0 0 0 0.10 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
mix-blend-mode: overlay;
opacity: 0.45;
pointer-events: none;
z-index: 0;
}
/* ── top bar ──────────────────────────────────────────────── */
.topbar {
position: absolute;
top: 56px;
left: 80px;
right: 80px;
display: flex;
align-items: center;
justify-content: space-between;
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 13px;
letter-spacing: 0.18em;
color: var(--ink-mute);
text-transform: uppercase;
z-index: 2;
}
.topbar .center {
color: var(--ink);
letter-spacing: 0.14em;
}
.topbar .dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 12px rgba(232,125,62,0.6);
margin-right: 10px;
vertical-align: middle;
}
.topbar .right span + span { margin-left: 6px; }
.topbar .sep { color: var(--ink-faint); margin: 0 8px; }
.hairline {
position: absolute;
top: 96px;
left: 80px;
right: 80px;
height: 1px;
background: linear-gradient(to right, transparent, var(--rule) 8%, var(--rule) 92%, transparent);
z-index: 1;
}
/* ── left column ──────────────────────────────────────────── */
.left {
position: absolute;
top: 160px;
left: 80px;
width: 880px;
z-index: 3;
}
.badges {
display: flex;
gap: 14px;
margin-bottom: 40px;
}
.badge {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 13px;
letter-spacing: 0.16em;
text-transform: uppercase;
padding: 8px 18px;
border-radius: 999px;
color: var(--ink);
border: 1px solid var(--rule);
background: rgba(40,30,20,0.5);
}
.badge.accent {
color: var(--accent);
border-color: rgba(232,125,62,0.5);
background: rgba(232,125,62,0.10);
}
.badge::before {
content: "•";
color: var(--accent);
margin-right: 8px;
}
h1.headline {
font-family: "Fraunces", "Playfair Display", Georgia, serif;
font-weight: 500;
font-size: 88px;
line-height: 1.02;
letter-spacing: -0.025em;
color: var(--ink);
margin: 0 0 32px 0;
font-variation-settings: "opsz" 144;
}
h1.headline em {
font-style: italic;
color: var(--accent);
font-weight: 600;
}
h1.headline .ul {
display: inline-block;
position: relative;
}
h1.headline .ul::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 8px;
height: 4px;
background: var(--accent);
border-radius: 2px;
}
.lede {
max-width: 680px;
font-size: 17px;
line-height: 1.6;
color: var(--ink-mute);
font-weight: 300;
letter-spacing: 0.005em;
}
.lede + .lede { margin-top: 14px; }
.lede b {
color: var(--ink);
font-weight: 600;
}
.lede em {
font-style: italic;
color: var(--accent);
font-weight: 500;
}
/* ── stats (inside left column) ───────────────────────────── */
.stats {
margin-top: 44px;
z-index: 3;
}
.stats-rule {
height: 1px;
background: linear-gradient(to right, var(--rule) 0%, var(--rule) 90%, transparent);
margin-bottom: 24px;
}
.stats-row {
display: flex;
gap: 56px;
}
.stat .num {
font-family: "Fraunces", serif;
font-weight: 500;
font-size: 52px;
line-height: 1;
letter-spacing: -0.02em;
color: var(--ink);
font-variation-settings: "opsz" 144;
}
.stat.zero .num { color: var(--accent); font-style: italic; }
.stat .label {
margin-top: 10px;
font-family: "JetBrains Mono", monospace;
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-mute);
line-height: 1.4;
}
/* ── bottom hairline ──────────────────────────────────────── */
.bottom-rule {
position: absolute;
left: 80px;
right: 80px;
bottom: 56px;
height: 1px;
background: linear-gradient(to right, transparent, var(--rule) 8%, var(--rule) 92%, transparent);
z-index: 1;
}
.bottom-meta {
position: absolute;
left: 80px;
right: 80px;
bottom: 30px;
display: flex;
justify-content: space-between;
font-family: "JetBrains Mono", monospace;
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-faint);
z-index: 2;
}
/* ── right column · floating screenshots ──────────────────── */
.stage {
position: absolute;
top: 160px;
right: 60px;
width: 1200px;
height: 760px;
z-index: 2;
pointer-events: none;
}
.card {
position: absolute;
border-radius: 14px;
overflow: hidden;
background: #221b14;
box-shadow:
0 30px 60px -16px rgba(0,0,0,0.7),
0 14px 30px -8px rgba(0,0,0,0.55),
0 0 0 1px rgba(255,236,200,0.06) inset;
}
.card .chrome {
position: absolute;
top: 0; left: 0; right: 0;
height: 30px;
background: linear-gradient(180deg, rgba(28,22,16,0.92), rgba(20,15,11,0.88));
border-bottom: 1px solid rgba(255,236,200,0.05);
display: flex;
align-items: center;
padding: 0 12px;
z-index: 2;
}
.card .chrome .tag {
font-family: "JetBrains Mono", monospace;
font-size: 9px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-mute);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card .chrome .tag .sep { color: var(--ink-faint); margin: 0 6px; }
.card .chrome .dots {
display: flex; gap: 5px; margin-right: 10px;
flex-shrink: 0;
}
.card .chrome .dots i {
width: 7px; height: 7px; border-radius: 50%;
background: #3a2e22;
display: inline-block;
}
.card img {
display: block;
object-fit: cover;
object-position: center top;
margin-top: 30px;
width: 100%;
height: calc(100% - 30px);
}
/* individual placements — 3 cards, spread across full right half */
.card-1 { /* deck-guizang-editorial — back, top-right */
top: 0;
right: 100px;
width: 540px;
height: 335px;
transform: rotate(-4deg);
z-index: 1;
}
.card-2 { /* deck-swiss-international — front, mid, leftmost-reaching */
top: 200px;
right: 380px;
width: 600px;
height: 375px;
transform: rotate(2.5deg);
z-index: 3;
}
.card-3 { /* video-hyperframes — bottom-right */
top: 420px;
right: 30px;
width: 540px;
height: 335px;
transform: rotate(-2deg);
z-index: 2;
}
/* ── orange "open source" sphere ──────────────────────────── */
.sphere {
position: absolute;
top: 150px;
right: 110px;
width: 86px;
height: 86px;
border-radius: 50%;
background:
radial-gradient(circle at 32% 28%, #ffb988 0%, #ec8c4b 30%, #c95f24 65%, #8c3d12 100%);
box-shadow:
0 0 60px 8px rgba(232,125,62,0.35),
0 0 120px 20px rgba(232,125,62,0.12),
inset -8px -10px 24px rgba(0,0,0,0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
}
.sphere span {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.16em;
line-height: 1.15;
color: #1a0e06;
text-align: center;
text-transform: uppercase;
}
/* mid-canvas pull quote bridging headline & cards */
.pullquote {
position: absolute;
left: 980px;
top: 760px;
width: 360px;
z-index: 4;
transform: rotate(-1.5deg);
}
.pullquote .mark {
font-family: "Fraunces", serif;
font-style: italic;
font-size: 96px;
line-height: 0.6;
color: var(--accent);
opacity: 0.55;
margin-bottom: -10px;
}
.pullquote .q {
font-family: "Fraunces", serif;
font-style: italic;
font-weight: 500;
font-size: 22px;
line-height: 1.35;
color: var(--ink);
letter-spacing: 0.005em;
}
.pullquote .attr {
margin-top: 14px;
font-family: "JetBrains Mono", monospace;
font-size: 10px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-faint);
}
/* watermark serial number on left edge */
.serial {
position: absolute;
top: 50%;
left: 28px;
transform: translateY(-50%) rotate(-90deg);
transform-origin: left center;
font-family: "JetBrains Mono", monospace;
font-size: 11px;
letter-spacing: 0.4em;
color: var(--ink-faint);
text-transform: uppercase;
z-index: 2;
}
</style>
</head>
<body>
<div class="cover">
<!-- top metadata bar -->
<div class="topbar">
<div class="left-meta">HTML ANYTHING · MANIFESTO · 2026 EDITION</div>
<div class="center"><span class="dot"></span>html.anything</div>
<div class="right">
<span>COVER</span><span class="sep">·</span><span>01 / 09</span><span class="sep">·</span><span>LOCAL AI · ZERO API KEY</span>
</div>
</div>
<div class="hairline"></div>
<!-- LEFT column -->
<div class="left">
<div class="badges">
<div class="badge accent">Apache 2.0</div>
<div class="badge">Local-first · Zero API key</div>
</div>
<h1 class="headline">
Markdown is the draft.<br/>
<em>HTML</em> is what humans <span class="ul">read</span>.
</h1>
<p class="lede">
The <b>agentic HTML editor</b> — in the agentic era, you don't hand-edit
docs anymore, so your local <b>Claude Code</b> (and 7 other agent CLIs)
should write the format the reader actually wants: <em>HTML</em>, not
Markdown. One file, ready to paste into <b>WeChat · X · Zhihu</b>.
</p>
<div class="stats">
<div class="stats-rule"></div>
<div class="stats-row">
<div class="stat">
<div class="num">75</div>
<div class="label">Composable<br/>Skills</div>
</div>
<div class="stat">
<div class="num">09</div>
<div class="label">Surface<br/>Modes</div>
</div>
<div class="stat">
<div class="num">08</div>
<div class="label">Coding<br/>Agents</div>
</div>
<div class="stat zero">
<div class="num">0</div>
<div class="label">API Key /<br/>Vendor Cloud</div>
</div>
</div>
</div>
</div>
<!-- RIGHT column · floating cards -->
<div class="stage">
<!-- back: deck-guizang-editorial -->
<div class="card card-1">
<div class="chrome">
<div class="dots"><i></i><i></i><i></i></div>
<div class="tag">DECK<span class="sep">·</span>GUIZANG EDITORIAL</div>
</div>
<img src="screenshots/skills/deck-guizang-editorial.png" alt="" />
</div>
<!-- front-middle: deck-swiss-international -->
<div class="card card-2">
<div class="chrome">
<div class="dots"><i></i><i></i><i></i></div>
<div class="tag">DECK<span class="sep">·</span>SWISS INTERNATIONAL</div>
</div>
<img src="screenshots/skills/deck-swiss-international.png" alt="" />
</div>
<!-- bottom-right: video-hyperframes -->
<div class="card card-3">
<div class="chrome">
<div class="dots"><i></i><i></i><i></i></div>
<div class="tag">VIDEO<span class="sep">·</span>HYPERFRAMES STORYBOARD</div>
</div>
<img src="screenshots/skills/video-hyperframes.png" alt="" />
</div>
</div>
<!-- mid-canvas pull quote bridging headline & right cards -->
<div class="pullquote">
<div class="mark"></div>
<div class="q">Markdown is good for the writer.<br/>HTML is good for the reader.</div>
<div class="attr">— Claude Code Team · 2026</div>
</div>
<!-- "open source" sphere -->
<div class="sphere"><span>OPEN<br/>SOURCE</span></div>
<!-- bottom hairline + meta -->
<div class="bottom-rule"></div>
<div class="bottom-meta">
<div>GITHUB.COM/PFTOM/HTML-EVERYTHING</div>
<div>MARKDOWN → HTML · DECK · POSTER · VIDEO FRAME</div>
<div>APACHE-2.0 · 2026</div>
</div>
<div class="serial">HE-2026 · COVER · 0001</div>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 904 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

+25
View File
@@ -0,0 +1,25 @@
# e2e/AGENTS.md
Follow root repository instructions first when present. This package owns user-level end-to-end smoke tests and Playwright UI automation only.
## Directory layout
- `ui/`: flat Playwright UI automation test files only. Keep helpers, resources, and non-Playwright harnesses out of this directory.
- `scripts/`: Playwright auxiliary subcommands such as artifact cleanup; it must not wrap `playwright test`.
## Naming and tools
- UI files must be flat `*.test.ts` Playwright tests.
- Do not add app unit tests, component tests, JSX/TSX, jsdom, Testing Library, or Next-private harnesses under `e2e/`.
- Do not add Playwright cases under `next/`; `e2e/` is the only source of truth for browser-level cases.
## Commands
Run commands from the repository root:
```bash
pnpm -F @html-anything/e2e typecheck
pnpm -F @html-anything/e2e exec tsx scripts/playwright.ts clean
pnpm -F @html-anything/e2e exec playwright test -c playwright.config.ts --list
pnpm -F @html-anything/e2e test
```
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@html-anything/e2e",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"scripts": {
"test": "playwright test -c playwright.config.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"playwright:clean": "tsx scripts/playwright.ts clean"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@types/node": "^20",
"jszip": "^3.10.1",
"tsx": "^4.22.1",
"typescript": "^5"
}
}
+43
View File
@@ -0,0 +1,43 @@
import { defineConfig, devices } from "@playwright/test";
const webPort = Number(process.env.HTML_ANYTHING_E2E_PORT) || 3317;
const baseURL = `http://127.0.0.1:${webPort}`;
export default defineConfig({
testDir: "./ui",
outputDir: "./ui/reports/test-results",
fullyParallel: true,
timeout: 30_000,
expect: {
timeout: 7_500,
},
reporter: process.env.CI
? [
["github"],
["list"],
["html", { open: "never", outputFolder: "./ui/reports/playwright-html-report" }],
["json", { outputFile: "./ui/reports/results.json" }],
["junit", { outputFile: "./ui/reports/junit.xml" }],
]
: [
["list"],
["html", { open: "never", outputFolder: "./ui/reports/playwright-html-report" }],
],
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
},
webServer: {
command: `pnpm -F @html-anything/next build && pnpm -F @html-anything/next exec next start -p ${webPort}`,
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+51
View File
@@ -0,0 +1,51 @@
import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const e2eDir = path.resolve(scriptDir, "..");
const uiDir = path.join(e2eDir, "ui");
type Command = () => Promise<void>;
const commands: Record<string, Command> = {
clean: cleanArtifacts,
help: async () => printUsage(),
};
const commandName = process.argv[2] ?? "help";
const command = commands[commandName];
if (command == null) {
console.error(`Unknown e2e Playwright helper command: ${commandName}`);
printUsage();
process.exitCode = 1;
} else {
await command();
}
async function cleanArtifacts(): Promise<void> {
const reportsDir = path.join(uiDir, "reports");
const targets = [
path.join(uiDir, "test-results"),
path.join(reportsDir, "test-results"),
path.join(reportsDir, "playwright-html-report"),
path.join(reportsDir, "results.json"),
path.join(reportsDir, "junit.xml"),
path.join(uiDir, ".DS_Store"),
];
await Promise.all(targets.map((target) => rm(target, { recursive: true, force: true })));
await mkdir(path.join(reportsDir, "test-results"), { recursive: true });
console.log("Cleaned e2e UI Playwright artifacts.");
}
function printUsage(): void {
console.log(`Usage: tsx scripts/playwright.ts <command>
Commands:
clean Remove e2e UI Playwright runtime data and reports
help Show this help
`);
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["playwright.config.ts", "scripts/**/*.ts", "ui/**/*.ts"],
"exclude": ["node_modules", "ui/reports", "ui/test-results"]
}
+103
View File
@@ -0,0 +1,103 @@
import { expect, test, type Page } from "@playwright/test";
const STORE_KEY = "html-everything-store";
const generatedHtml = `<!doctype html>
<html>
<head><title>Deploy Fixture</title></head>
<body><main><h1>Deploy Fixture</h1></main></body>
</html>`;
async function seedStore(page: Page) {
const now = 1_700_000_000_000;
const task = {
id: "task_deploy_ui",
name: "Deploy UI fixture",
content: "Deploy UI fixture",
format: "html",
templateId: "article-magazine",
html: generatedHtml,
status: "done",
log: [],
stats: { outputBytes: generatedHtml.length, deltaCount: 1 },
createdAt: now,
updatedAt: now,
};
await page.addInitScript(
({ key, taskFixture }) => {
window.localStorage.setItem(
key,
JSON.stringify({
state: {
tasks: [taskFixture],
activeTaskId: taskFixture.id,
selectedAgent: "test-agent",
agentModels: {},
agentBinOverrides: {},
welcomeAck: true,
sidebarCollapsed: false,
locale: "en",
layoutMode: "split",
},
version: 7,
}),
);
},
{
key: STORE_KEY,
taskFixture: task,
},
);
}
test.describe("Deploy control", () => {
test("dismisses the latest deploy result without deleting history", async ({
page,
}) => {
await seedStore(page);
await page.route("**/api/deploy/config?provider=vercel", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ configured: true }),
}),
);
await page.route("**/api/deploy", async (route, request) => {
if (request.method() !== "POST") {
await route.continue();
return;
}
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
providerId: "vercel",
url: "https://deploy-fixture.vercel.app",
deploymentId: "dpl_fixture",
target: "production",
status: "ready",
}),
});
});
await page.goto("/");
const publishButton = page.getByRole("button", { name: /publish/i });
await expect(publishButton).toBeEnabled();
await publishButton.click();
await expect(page.getByText("Live at")).toBeVisible();
await expect(
page.getByRole("link", { name: "https://deploy-fixture.vercel.app" }),
).toBeVisible();
await page.getByRole("button", { name: "Dismiss" }).click();
await expect(page.getByText("Live at")).toHaveCount(0);
await page.getByTitle("Past deployments").click();
await expect(
page.getByRole("link", { name: "https://deploy-fixture.vercel.app" }),
).toBeVisible();
});
});
+167
View File
@@ -0,0 +1,167 @@
import { expect, test, type Page } from "@playwright/test";
import JSZip from "jszip";
import { readFile } from "node:fs/promises";
type SeedOptions = {
html: string;
content?: string;
locale?: "en" | "zh-CN";
};
const STORE_KEY = "html-everything-store";
const plainHtml = `<!doctype html>
<html>
<head><title>Plain Export</title></head>
<body><main><h1>Plain Export</h1><p>No video frames here.</p></main></body>
</html>`;
const hyperframesHtml = `<!doctype html>
<html>
<head>
<title>Sample Reel</title>
<style>body { margin: 0; background: #101827; color: white; }</style>
</head>
<body class="deck-shell" style="font-family: Inter, sans-serif;">
<section class="frame active" data-duration="2000" data-transition="fade">
<h1>Opening Frame</h1>
<!-- frame:1 duration:2000 transition:fade -->
</section>
<section class="frame" data-duration="3000" data-transition="cut">
<h1>Closing Frame</h1>
<!-- frame:2 duration:3000 transition:cut -->
</section>
<!-- HYPERFRAMES_META: {"frames":[{"i":1,"duration":2000,"transition":"fade","scene":"Opening"},{"i":2,"duration":3000,"transition":"cut","scene":"Closing"}]} -->
</body>
</html>`;
async function seedStore(page: Page, opts: SeedOptions) {
const now = 1_700_000_000_000;
const task = {
id: "task_export_ui",
name: "Export UI fixture",
content: opts.content ?? "Export UI fixture",
format: "html",
templateId: "video-hyperframes",
html: opts.html,
status: "done",
log: [],
stats: { outputBytes: opts.html.length, deltaCount: 1 },
createdAt: now,
updatedAt: now,
};
await page.addInitScript(
({ key, taskFixture, locale }) => {
window.localStorage.setItem(
key,
JSON.stringify({
state: {
tasks: [taskFixture],
activeTaskId: taskFixture.id,
selectedAgent: "test-agent",
agentModels: {},
welcomeAck: true,
sidebarCollapsed: false,
locale,
layoutMode: "split",
},
version: 5,
}),
);
},
{
key: STORE_KEY,
taskFixture: task,
locale: opts.locale ?? "en",
},
);
}
test.describe("Export menu", () => {
test("keeps Remotion hidden for regular HTML exports", async ({ page }) => {
await seedStore(page, { html: plainHtml });
await page.goto("/");
const exportButton = page.getByRole("button", { name: /export/i });
await expect(exportButton).toBeEnabled();
await exportButton.click();
const menu = page.getByTestId("export-menu");
await expect(menu.getByText("Copy to platform")).toBeVisible();
await expect(menu.getByRole("button", { name: /HTML source/ })).toBeVisible();
await expect(menu.getByRole("button", { name: /\.html single file/ })).toBeVisible();
await expect(menu.getByText(/Hyperframes/)).toHaveCount(0);
await expect(menu.getByRole("button", { name: /Remotion project/ })).toHaveCount(0);
});
test("exports a Hyperframes Remotion project zip from the UI", async ({ page }) => {
await seedStore(page, { html: hyperframesHtml });
await page.goto("/");
await page.getByRole("button", { name: /export/i }).click();
const menu = page.getByTestId("export-menu");
await expect(menu.getByText("Hyperframes · 2 frames")).toBeVisible();
const remotionButton = menu.getByRole("button", { name: /Remotion project \(\.zip\)/ });
await expect(remotionButton).toBeVisible();
const downloadPromise = page.waitForEvent("download");
await remotionButton.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^sample-reel-remotion-\d+\.zip$/);
const downloadPath = await download.path();
expect(downloadPath).toBeTruthy();
const zip = await JSZip.loadAsync(await readFile(downloadPath!));
expect(Object.keys(zip.files).sort()).toEqual(
expect.arrayContaining([
"README.md",
"hyperframes.html",
"hyperframes.meta.json",
"package.json",
"public/frames/frame-01.html",
"public/frames/frame-02.html",
"remotion.config.ts",
"src/Frame.tsx",
"src/Root.tsx",
"src/Video.tsx",
"src/index.ts",
"tsconfig.json",
]),
);
const rootTsx = await zip.file("src/Root.tsx")!.async("string");
const videoTsx = await zip.file("src/Video.tsx")!.async("string");
const frameTsx = await zip.file("src/Frame.tsx")!.async("string");
const frameOne = await zip.file("public/frames/frame-01.html")!.async("string");
const readme = await zip.file("README.md")!.async("string");
expect(rootTsx).toContain("durationInFrames={138}");
expect(frameTsx).toContain('staticFile(src)');
expect(videoTsx).toContain("TransitionSeries.Transition");
expect(videoTsx).toContain("presentation={fade()}");
expect(videoTsx).toContain('durationInFrames: 60, transition: "fade", scene: "Opening"');
expect(videoTsx).toContain('durationInFrames: 90, transition: "cut", scene: "Closing"');
expect(frameOne).toContain('<section class="frame active" data-duration="2000">');
expect(frameOne).toContain("Opening Frame");
expect(readme).toContain("2 frames");
expect(readme).toContain("Cross-fades use");
await expect(page.getByText(/Remotion project zipped/)).toBeVisible();
});
test("shows the Hyperframes export affordance in Chinese locale", async ({ page }) => {
await seedStore(page, { html: hyperframesHtml, locale: "zh-CN" });
await page.goto("/");
await page.getByRole("button", { name: /导出/ }).click();
const menu = page.getByTestId("export-menu");
await expect(menu.getByText("Hyperframes · 共 2 帧")).toBeVisible();
await expect(menu.getByRole("button", { name: /Remotion 项目 \(\.zip\)/ })).toBeVisible();
});
});
+131
View File
@@ -0,0 +1,131 @@
/**
* End-to-end verification that the API surface refuses requests whose `Host`
* header isn't on the loopback allowlist. The exact rebinding attack we are
* defending against would deliver these requests from a real browser tab
* after a DNS flip; here we use a raw `request.fetch` so we can forge the
* header directly.
*
* The Playwright webServer in `e2e/playwright.config.ts` runs
* `next start -p 3317`, which binds to every interface by default. The
* default `baseURL` resolves to `127.0.0.1:3317` — the loopback path. To
* exercise the rejection path we override the `Host` header to attacker-
* controlled values while still dialing the real loopback IP.
*
* The accept-loopback tests prove the default dev path keeps working —
* gating on Host must not break the common case where the user opens
* `http://localhost:3317/` in a browser on their own machine.
*/
import { test, expect, request as playwrightRequest } from "@playwright/test";
const API_PATHS = [
"/api/agents",
"/api/templates",
"/api/deploy/config?provider=vercel",
] as const;
// Matches the `webServer.url` in e2e/playwright.config.ts (port 3317). Used as
// a fallback when the `baseURL` fixture is undefined — exactOptionalPropertyTypes
// rejects passing `string | undefined` to newContext's `baseURL?: string`.
const DEFAULT_BASE_URL = "http://127.0.0.1:3317";
test.describe("API host-header validation", () => {
test("accepts loopback Host (127.0.0.1) — default dev path", async ({ baseURL }) => {
const ctx = await playwrightRequest.newContext({
baseURL: baseURL ?? DEFAULT_BASE_URL,
extraHTTPHeaders: { Host: "127.0.0.1:3317" },
});
for (const p of API_PATHS) {
const r = await ctx.get(p);
// Any 2xx / 4xx that ISN'T 403 from this middleware passes — the route
// may legitimately 400 (missing query / not configured) but it should
// not be the host-rejection 403.
if (r.status() === 403) {
const body = await r.json().catch(() => ({}));
expect(body.error, `loopback host rejected on ${p}: ${JSON.stringify(body)}`).not.toBe(
"Host not allowed",
);
}
}
await ctx.dispose();
});
test("accepts localhost Host — default dev path", async ({ baseURL }) => {
const ctx = await playwrightRequest.newContext({
baseURL: baseURL ?? DEFAULT_BASE_URL,
extraHTTPHeaders: { Host: "localhost:3317" },
});
const r = await ctx.get("/api/agents");
expect(r.status()).not.toBe(403);
await ctx.dispose();
});
test("rejects attacker.example Host on every API path", async ({ baseURL }) => {
const ctx = await playwrightRequest.newContext({
baseURL: baseURL ?? DEFAULT_BASE_URL,
extraHTTPHeaders: { Host: "attacker.example" },
});
for (const p of API_PATHS) {
const r = await ctx.get(p);
expect(r.status(), `${p} should reject attacker.example`).toBe(403);
const body = await r.json();
expect(body.error).toBe("Host not allowed");
}
await ctx.dispose();
});
test("rejects POST /api/convert with attacker Host (RCE vector)", async ({ baseURL }) => {
const ctx = await playwrightRequest.newContext({
baseURL: baseURL ?? DEFAULT_BASE_URL,
extraHTTPHeaders: { Host: "attacker.example" },
});
const r = await ctx.post("/api/convert", {
data: { agent: "claude", templateId: "deck-swiss-international", content: "ignore" },
});
expect(r.status(), "POST /api/convert must reject forged Host (RCE vector)").toBe(403);
await ctx.dispose();
});
test("rejects PUT /api/deploy/config with attacker Host (token-write vector)", async ({
baseURL,
}) => {
const ctx = await playwrightRequest.newContext({
baseURL: baseURL ?? DEFAULT_BASE_URL,
extraHTTPHeaders: { Host: "attacker.example" },
});
const r = await ctx.put("/api/deploy/config?provider=vercel", {
data: { token: "attacker-token" },
});
expect(r.status()).toBe(403);
await ctx.dispose();
});
test("rejects subdomain-tricks (localhost.attacker.example)", async ({ baseURL }) => {
const ctx = await playwrightRequest.newContext({
baseURL: baseURL ?? DEFAULT_BASE_URL,
extraHTTPHeaders: { Host: "localhost.attacker.example" },
});
const r = await ctx.get("/api/agents");
expect(r.status()).toBe(403);
await ctx.dispose();
});
test("rejects empty Host", async ({ baseURL }) => {
// A single space is deterministically transmitted; per RFC 7230 the
// receiving parser strips the surrounding OWS and the server sees an
// empty `host`. The validator's `stripPort.trim()` reduces it to "" and
// `isAllowedHost("")` returns false → 403. An empty-string value would
// be at the mercy of Playwright's header-serialization behavior (it may
// drop the entry, in which case the request goes out with the default
// loopback Host and the test passes for the wrong reason).
const ctx = await playwrightRequest.newContext({
baseURL: baseURL ?? DEFAULT_BASE_URL,
extraHTTPHeaders: { Host: " " },
});
const r = await ctx.get("/api/agents");
// Some HTTP stacks reject empty Host headers themselves with 400 before
// it reaches middleware. Either 400 or 403 is acceptable; the key
// invariant is "no 200".
expect([400, 403]).toContain(r.status());
await ctx.dispose();
});
});
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@html-anything/next",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"clsx": "^2.1.1",
"diff": "^9.0.0",
"dompurify": "^3.4.2",
"highlight.js": "^11.11.1",
"idb": "^8.0.3",
"jszip": "^3.10.1",
"juice": "^11.1.1",
"lucide-react": "^1.14.0",
"marked": "^18.0.3",
"modern-screenshot": "^4.7.0",
"next": "16.2.6",
"papaparse": "^5.5.3",
"pptxgenjs": "^4.0.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"unpdf": "^1.2.2",
"xlsx": "^0.18.5",
"zustand": "^5.0.13"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/dompurify": "^3.2.0",
"@types/node": "^20",
"@types/papaparse": "^5.5.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"fake-indexeddb": "^6.2.5",
"happy-dom": "^20.9.0",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.1.6"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { detectAgents } from "@/lib/agents/detect";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const agents = detectAgents();
return NextResponse.json({
agents,
installedCount: agents.filter((a) => a.available).length,
platform: process.platform,
});
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "detection failed" },
{ status: 500 },
);
}
}
+164
View File
@@ -0,0 +1,164 @@
import { NextRequest } from "next/server";
import { invokeAgent } from "@/lib/agents/invoke";
import { loadSkill } from "@/lib/templates/loader";
import { assemblePrompt } from "@/lib/templates/shared";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Body = {
agent: string;
templateId: string;
content: string;
format?: string;
model?: string;
cwd?: string;
/**
* Optional absolute path to the agent binary. The Settings UI lets the
* user override auto-detection when their CLI lives somewhere our PATH
* scan doesn't cover (Scoop on Windows, custom installs, etc.).
*/
binOverride?: string;
/** When the task already has a generated HTML, the client sends both the
* prior HTML and the prior content. The agent is then asked for a
* minimal-diff edit (preserve design, only change what the content diff
* implies). Saves output tokens AND prevents creative drift between runs. */
editFromHtml?: string;
editFromContent?: string;
};
function buildEditPrompt(args: {
templateName: string;
templateAspect: string;
newContent: string;
oldContent: string;
oldHtml: string;
format: string;
}): string {
return `你正在执行一次**最小化差异编辑** (diff-edit), 不是从 0 重新生成。
模板风格: ${args.templateName} (${args.templateAspect})
输入格式: ${args.format}
【硬性规则】
1. 仅输出完整的、修改后的 HTML。第一个字符必须是 \`<\`, 最后必须是 \`</html>\`
2. **不要**用 markdown 围栏包裹, 不要任何解释性文字。
3. **禁止使用 Write / Edit / MultiEdit / Bash 等文件工具** — HTML 必须直接在助手回复正文里流式输出, 不要存到 \`.html\` 文件再回复"已输出至 …"。
4. 保留原 HTML 的 \`<head>\` (CDN / 字体 / 样式 / meta), 保留所有不需要变化的 DOM 结构 — 字体、配色、布局、栅格、组件结构、动画都不许改。
5. 仅根据 "旧内容 vs 新内容" 的差异, 替换或调整对应的文字 / 数据节点。
6. 如果新内容增加了条目, 沿用原有的卡片 / 行 / slide / 章节结构添加; 如果删除了条目, 移除对应的元素。
7. 如果新旧内容只差几个字, 也只改那几个字 — 不要顺手 "优化" 或 "重排"。
8. 不要捏造数据。新内容里没有的就不要写。
【旧内容】
${args.oldContent}
【新内容】
${args.newContent}
【已有 HTML — 请基于此修改, 输出完整的修改后版本】
${args.oldHtml}
`;
}
export async function POST(req: NextRequest) {
let body: Body;
try {
body = (await req.json()) as Body;
} catch {
return new Response("invalid JSON body", { status: 400 });
}
const {
agent,
templateId,
content,
format = "text",
model,
cwd,
binOverride,
editFromHtml,
editFromContent,
} = body;
if (!agent || !templateId || !content) {
return new Response("missing required fields: agent, templateId, content", {
status: 400,
});
}
const skill = loadSkill(templateId);
if (!skill) {
return new Response(`unknown template: ${templateId}`, { status: 400 });
}
let prompt: string;
if (editFromHtml && editFromContent) {
prompt = buildEditPrompt({
templateName: skill.zhName,
templateAspect: skill.aspectHint,
newContent: content,
oldContent: editFromContent,
oldHtml: editFromHtml,
format,
});
} else {
prompt = assemblePrompt({ body: skill.body, content, format });
}
const abortCtl = new AbortController();
req.signal?.addEventListener("abort", () => abortCtl.abort(), { once: true });
const stream = invokeAgent({
agent,
prompt,
model,
cwd,
binOverride,
signal: abortCtl.signal,
});
const sse = new ReadableStream({
async start(controller) {
const enc = new TextEncoder();
let outClosed = false;
const send = (event: string, data: unknown) => {
if (outClosed) return;
try {
controller.enqueue(
enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
);
} catch {
outClosed = true;
}
};
const reader = stream.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (!value) continue;
send(value.type, value);
}
} catch (err) {
send("error", {
message: err instanceof Error ? err.message : String(err),
});
} finally {
outClosed = true;
try {
controller.close();
} catch {}
}
},
cancel() {
abortCtl.abort();
},
});
return new Response(sse, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import {
clearDeployConfig,
DeployError,
isDeployProviderId,
publicDeployConfigForProvider,
readDeployConfig,
writeDeployConfig,
type DeployConfig,
type DeployProviderId,
} from "@/lib/deploy/config";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/deploy/config?provider=vercel
* → public-shape config (token masked)
* PUT /api/deploy/config?provider=vercel
* body: { token?, teamId?, teamSlug?, accountId? }
* → updated public-shape config
* DELETE /api/deploy/config?provider=vercel
* → unconfigured public-shape config (deletes the on-disk credential file)
*
* Tokens never leave the server in plaintext. Once configured, GET returns
* `tokenMask: "saved-vercel-token"` (or the cloudflare equivalent); the
* client sends that mask back unchanged when it wants to keep the existing
* value during a partial update of `teamId` etc.
*/
function deployErrorResponse(err: unknown): NextResponse {
if (err instanceof DeployError) {
return NextResponse.json(
{ error: err.message, code: err.code, details: err.details },
{ status: err.status },
);
}
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
function readProvider(req: NextRequest): DeployProviderId {
const provider = req.nextUrl.searchParams.get("provider") ?? "vercel";
if (!isDeployProviderId(provider)) {
throw new DeployError(`Unknown deploy provider: ${provider}`, 400);
}
return provider;
}
export async function GET(req: NextRequest) {
try {
const providerId = readProvider(req);
const config = await readDeployConfig(providerId);
return NextResponse.json(publicDeployConfigForProvider(providerId, config));
} catch (err) {
return deployErrorResponse(err);
}
}
export async function PUT(req: NextRequest) {
try {
const providerId = readProvider(req);
let body: Partial<DeployConfig>;
try {
body = (await req.json()) as Partial<DeployConfig>;
} catch {
return NextResponse.json(
{ error: "Invalid JSON body" },
{ status: 400 },
);
}
const updated = await writeDeployConfig(providerId, body);
return NextResponse.json(updated);
} catch (err) {
return deployErrorResponse(err);
}
}
export async function DELETE(req: NextRequest) {
try {
const providerId = readProvider(req);
const cleared = await clearDeployConfig(providerId);
return NextResponse.json(cleared);
} catch (err) {
return deployErrorResponse(err);
}
}
+120
View File
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from "next/server";
import {
DeployError,
isDeployProviderId,
readDeployConfig,
type DeployProviderId,
} from "@/lib/deploy/config";
import { deployToVercel } from "@/lib/deploy/vercel";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Allow long-running deployments — Vercel's response can take 6090 s
// while the deployment finishes building + URL becomes reachable.
export const maxDuration = 300;
type Body = {
taskId: string;
provider: DeployProviderId;
/** The HTML document to publish. Must be a complete document (we wrap
* defensively below if it isn't). */
html: string;
};
/**
* Wrap a fragment in a minimal HTML5 envelope if the agent emitted bare
* markup (no `<!DOCTYPE>` / `<html>`). Without this the deployed page
* loads in browser quirks mode and renders very differently.
*/
function ensureFullHtmlDocument(html: string): string {
if (!html.trim()) return html;
if (/<!doctype\s+html/i.test(html) || /<html[\s>]/i.test(html)) return html;
return [
"<!DOCTYPE html>",
'<html lang="en">',
"<head>",
'<meta charset="UTF-8">',
'<meta name="viewport" content="width=device-width, initial-scale=1.0">',
"<title>HTML Anything</title>",
"</head>",
"<body>",
html,
"</body>",
"</html>",
].join("\n");
}
function deployErrorResponse(err: unknown): NextResponse {
if (err instanceof DeployError) {
return NextResponse.json(
{ error: err.message, code: err.code, details: err.details },
{ status: err.status },
);
}
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
export async function POST(req: NextRequest) {
let body: Body;
try {
body = (await req.json()) as Body;
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { taskId, provider, html } = body;
if (!taskId || typeof taskId !== "string") {
return NextResponse.json(
{ error: "Missing or invalid taskId" },
{ status: 400 },
);
}
if (!provider || !isDeployProviderId(provider)) {
return NextResponse.json(
{ error: `Unknown deploy provider: ${provider}` },
{ status: 400 },
);
}
if (typeof html !== "string" || !html.trim()) {
return NextResponse.json(
{ error: "Empty HTML — run Convert first." },
{ status: 400 },
);
}
if (provider !== "vercel") {
// CF Pages support is planned for the next iteration. Surface a clear
// 501 rather than crashing on an unimplemented branch.
return NextResponse.json(
{
error:
"Cloudflare Pages deploy is not implemented yet. Vercel is currently the only supported provider.",
},
{ status: 501 },
);
}
try {
const config = await readDeployConfig(provider);
if (!config.token) {
throw new DeployError(
"Vercel token is not configured. Open Settings → Deploy to add one.",
400,
undefined,
"missing_token",
);
}
const fullHtml = ensureFullHtmlDocument(html);
const result = await deployToVercel({
config,
taskId,
files: [
{ file: "index.html", data: fullHtml, contentType: "text/html" },
],
});
return NextResponse.json(result);
} catch (err) {
return deployErrorResponse(err);
}
}
+113
View File
@@ -0,0 +1,113 @@
import { NextRequest } from "next/server";
import { invokeAgent } from "@/lib/agents/invoke";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Body = {
agent: string;
/** User's natural-language request, e.g. "write a tweet about X". */
instruction: string;
/** Existing markdown in the editor — given as context so the agent can
* continue the user's voice instead of writing in isolation. */
context?: string;
model?: string;
/** Optional absolute path to the agent binary; see /api/convert. */
binOverride?: string;
};
function buildDraftPrompt(args: { instruction: string; context: string }): string {
const ctx = args.context.trim();
return `你正在为用户起草一段 **markdown** 内容(不是 HTML,不是 JSON,不是代码)。
【硬性规则】
1. 只输出 markdown 正文,不要任何前后解释、不要 \`\`\`md 围栏、不要"以下是…"开头。
2. 第一个字符就是正文。最后一个字符是正文末尾。
3. 不要捏造数据、不要凭空添加引用链接。
4. 标题、列表、强调、引用、代码块按 markdown 语法书写。
5. 如果用户没有指定语言,使用与"已有内容"一致的语言;都没有就用中文。
6. 长度控制:除非用户明确要求长文,控制在 300 字以内。
【用户当前编辑器里的内容(可能为空)】
${ctx ? ctx : "(空)"}
【用户的需求】
${args.instruction}
`;
}
export async function POST(req: NextRequest) {
let body: Body;
try {
body = (await req.json()) as Body;
} catch {
return new Response("invalid JSON body", { status: 400 });
}
const { agent, instruction, context = "", model, binOverride } = body;
if (!agent || !instruction?.trim()) {
return new Response("missing required fields: agent, instruction", {
status: 400,
});
}
const prompt = buildDraftPrompt({ instruction, context });
const abortCtl = new AbortController();
req.signal?.addEventListener("abort", () => abortCtl.abort(), { once: true });
const stream = invokeAgent({
agent,
prompt,
model,
binOverride,
signal: abortCtl.signal,
});
const sse = new ReadableStream({
async start(controller) {
const enc = new TextEncoder();
let outClosed = false;
const send = (event: string, data: unknown) => {
if (outClosed) return;
try {
controller.enqueue(
enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
);
} catch {
outClosed = true;
}
};
const reader = stream.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (!value) continue;
send(value.type, value);
}
} catch (err) {
send("error", {
message: err instanceof Error ? err.message : String(err),
});
} finally {
outClosed = true;
try {
controller.close();
} catch {}
}
},
cancel() {
abortCtl.abort();
},
});
return new Response(sse, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { hostRejectedResponse, isHostAllowed } from "../host-guard";
describe("isHostAllowed", () => {
const original = {
allowed: process.env.HTML_ANYTHING_ALLOWED_HOSTS,
any: process.env.HTML_ANYTHING_ALLOW_ANY_HOST,
};
beforeEach(() => {
delete process.env.HTML_ANYTHING_ALLOWED_HOSTS;
delete process.env.HTML_ANYTHING_ALLOW_ANY_HOST;
});
afterEach(() => {
if (original.allowed === undefined) delete process.env.HTML_ANYTHING_ALLOWED_HOSTS;
else process.env.HTML_ANYTHING_ALLOWED_HOSTS = original.allowed;
if (original.any === undefined) delete process.env.HTML_ANYTHING_ALLOW_ANY_HOST;
else process.env.HTML_ANYTHING_ALLOW_ANY_HOST = original.any;
});
function reqWithHost(host: string | null): Request {
// undici (fetch-spec) forbids the "host" request header; encode the host
// into the URL instead. The guard reads `req.headers.get('host')` first
// and falls back to `new URL(req.url).host`, which matches reality —
// browsers populate Host from the URL they dial.
const url = host === null ? "file:///no-url-host" : `http://${host}/api/marketplace/install`;
return new Request(url, { method: "POST" });
}
it("allows loopback IPv4 with a port", () => {
expect(isHostAllowed(reqWithHost("127.0.0.1:3000"))).toBe(true);
});
it("allows literal localhost", () => {
expect(isHostAllowed(reqWithHost("localhost"))).toBe(true);
});
it("allows IPv6 loopback in bracketed form", () => {
expect(isHostAllowed(reqWithHost("[::1]:3000"))).toBe(true);
});
it("rejects an attacker-controlled hostname (the DNS-rebinding case)", () => {
expect(isHostAllowed(reqWithHost("evil.example.com"))).toBe(false);
});
it("rejects a request with no Host header", () => {
expect(isHostAllowed(reqWithHost(null))).toBe(false);
});
it("allows a hostname listed in HTML_ANYTHING_ALLOWED_HOSTS", () => {
process.env.HTML_ANYTHING_ALLOWED_HOSTS = "ha.local, my-box";
expect(isHostAllowed(reqWithHost("ha.local:3000"))).toBe(true);
expect(isHostAllowed(reqWithHost("my-box"))).toBe(true);
expect(isHostAllowed(reqWithHost("other.host"))).toBe(false);
});
it("accepts any host when HTML_ANYTHING_ALLOW_ANY_HOST=1 (trusted-proxy mode)", () => {
process.env.HTML_ANYTHING_ALLOW_ANY_HOST = "1";
expect(isHostAllowed(reqWithHost("anything.com"))).toBe(true);
});
});
describe("hostRejectedResponse", () => {
it("returns a 403 JSON response with an actionable hint", async () => {
const res = hostRejectedResponse();
expect(res.status).toBe(403);
const body = (await res.json()) as { error: string; hint: string };
expect(body.error).toBe("host_not_allowed");
expect(body.hint).toContain("HTML_ANYTHING_ALLOWED_HOSTS");
});
});
@@ -0,0 +1,78 @@
/**
* Per-route Host-header guard for marketplace install/uninstall.
*
* Why this lives next to the marketplace routes rather than at the middleware
* layer: a sibling PR (security/api-host-validation) introduces a global
* `/api/*` middleware that covers every API route. Until that lands, the
* marketplace POST is a particularly attractive DNS-rebinding target — it
* downloads and writes arbitrary user-supplied GitHub repos to disk and
* registers them as installable skills. So we ship a local check here that:
* - mirrors the same default (loopback-only) and env knobs
* (`HTML_ANYTHING_ALLOWED_HOSTS`, `HTML_ANYTHING_ALLOW_ANY_HOST`),
* - is independent of the middleware so it works whichever PR lands first,
* - becomes a redundant no-op once the global middleware also runs.
*
* Once the global host-validation middleware merges, this module can be
* deleted and the routes can rely on the middleware alone.
*/
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "0.0.0.0"]);
function stripPort(host: string): string {
const trimmed = host.trim().toLowerCase();
if (!trimmed) return "";
if (trimmed.startsWith("[")) {
const end = trimmed.indexOf("]");
if (end === -1) return trimmed;
return trimmed.slice(1, end);
}
const colon = trimmed.lastIndexOf(":");
if (colon === -1) return trimmed;
const tail = trimmed.slice(colon + 1);
return /^\d+$/.test(tail) ? trimmed.slice(0, colon) : trimmed;
}
function parseAllowlist(): Set<string> {
const raw = process.env.HTML_ANYTHING_ALLOWED_HOSTS;
if (!raw) return new Set();
return new Set(
raw
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
);
}
export function isHostAllowed(req: Request): boolean {
if (process.env.HTML_ANYTHING_ALLOW_ANY_HOST === "1") return true;
// Prefer the Host header — that's what browsers send and what the dev-server
// populates over the wire. Fall back to `new URL(req.url).host` because
// fetch-spec-compliant `Request` constructors (undici, browsers) forbid
// setting Host explicitly, and tests have to rely on the URL.
const rawHost = req.headers.get("host") ?? safeUrlHost(req.url);
if (!rawHost) return false;
const host = stripPort(rawHost);
if (!host) return false;
if (LOOPBACK_HOSTS.has(host)) return true;
return parseAllowlist().has(host);
}
function safeUrlHost(url: string): string | null {
try {
return new URL(url).host;
} catch {
return null;
}
}
export function hostRejectedResponse(): Response {
return new Response(
JSON.stringify({
error: "host_not_allowed",
hint:
"marketplace install/uninstall only accepts loopback Host. " +
"Add the hostname to HTML_ANYTHING_ALLOWED_HOSTS or set HTML_ANYTHING_ALLOW_ANY_HOST=1 behind a trusted proxy.",
}),
{ status: 403, headers: { "Content-Type": "application/json; charset=utf-8" } },
);
}
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { installFromGitHub, InstallError } from "@/lib/skills/install";
import { invalidateSkillsCache } from "@/lib/templates/loader";
import { hostRejectedResponse, isHostAllowed } from "../_lib/host-guard";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
if (!isHostAllowed(req)) return hostRejectedResponse();
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
}
const spec = (body as { source?: unknown } | null)?.source;
if (typeof spec !== "string" || !spec.trim()) {
return NextResponse.json({ error: "missing_source" }, { status: 400 });
}
try {
const result = await installFromGitHub(spec);
invalidateSkillsCache();
return NextResponse.json({ package: result.package });
} catch (err) {
if (err instanceof InstallError) {
return NextResponse.json({ error: err.code, message: err.message }, { status: 400 });
}
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: "install_failed", message }, { status: 500 });
}
}
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { uninstallPackage } from "@/lib/skills/install";
import { invalidateSkillsCache } from "@/lib/templates/loader";
import { hostRejectedResponse, isHostAllowed } from "../../_lib/host-guard";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Ctx = { params: Promise<{ id: string }> };
export async function DELETE(req: Request, ctx: Ctx) {
if (!isHostAllowed(req)) return hostRejectedResponse();
const { id } = await ctx.params;
if (!/^[a-z0-9._-]+__[a-z0-9._-]+$/i.test(id)) {
return NextResponse.json({ error: "invalid_id" }, { status: 400 });
}
const removed = await uninstallPackage(id);
if (!removed) {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
invalidateSkillsCache();
return NextResponse.json({ ok: true });
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { listPackages } from "@/lib/skills/registry";
import { hostRejectedResponse, isHostAllowed } from "./_lib/host-guard";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* List every installed marketplace package.
*
* Gated behind the same Host guard as install/uninstall — even though this
* is read-only, returning installed packages to a DNS-rebinding origin
* would leak repo owners / names / refs from the user's local app to any
* site they visit. Once the global `/api/*` middleware (PR #61) lands, the
* per-route check here is redundant; until then it must cover the whole
* marketplace surface, not just the write endpoints.
*/
export async function GET(req: Request) {
if (!isHostAllowed(req)) return hostRejectedResponse();
return NextResponse.json({ packages: listPackages() });
}
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { loadSkill } from "@/lib/templates/loader";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Ctx = { params: Promise<{ id: string }> };
/**
* Returns the skill's pre-shipped example as a JSON bundle the client can
* drop straight into `loadSample()` — saves the picker from making two
* round-trips (one for content, one for HTML) when the user hits "Preview".
*/
export async function GET(_req: Request, ctx: Ctx) {
const { id } = await ctx.params;
const skill = loadSkill(id);
if (!skill || !skill.example) {
return new Response(`no example for template: ${id}`, { status: 404 });
}
return NextResponse.json({
id: skill.example.id,
name: skill.example.name,
templateId: skill.id,
format: skill.example.format,
tagline: skill.example.tagline,
desc: skill.example.desc,
source: skill.example.source,
content: skill.exampleMd ?? "",
html: skill.exampleHtml ?? "",
});
}
@@ -0,0 +1,29 @@
import { loadSkill } from "@/lib/templates/loader";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Ctx = { params: Promise<{ id: string }> };
/**
* Returns the skill's `example.html` verbatim so it can be loaded into an
* `<iframe src=…>`. Lets the browser handle caching and avoids shipping every
* preview HTML through `srcDoc` (which would block the main thread when the
* gallery renders dozens of thumbnails at once).
*/
export async function GET(_req: Request, ctx: Ctx) {
const { id } = await ctx.params;
const skill = loadSkill(id);
if (!skill || !skill.exampleHtml) {
return new Response(`no preview for template: ${id}`, { status: 404 });
}
return new Response(skill.exampleHtml, {
headers: {
"Content-Type": "text/html; charset=utf-8",
// Aggressive caching is fine — contributors editing a preview can
// hard-refresh; the response key (skill id) doesn't get reused for
// different content.
"Cache-Control": "public, max-age=300",
},
});
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { listSkills } from "@/lib/templates/loader";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
const skills = listSkills();
return NextResponse.json(
{ templates: skills },
{
headers: {
// Browser cache for a few seconds — long enough to dedupe rapid
// refetches, short enough that a contributor dropping a new skill
// folder sees it on the next reload without restarting.
"Cache-Control": "public, max-age=5",
},
},
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+237
View File
@@ -0,0 +1,237 @@
@import "tailwindcss";
/* ── open-design palette (editor + landing hybrid) ──────────────── */
:root {
--app-font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--app-font-display: "Inter Tight", var(--app-font-sans);
--app-font-serif: "Playfair Display", ui-serif, Georgia, Cambria, "Times New Roman", serif;
--app-font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--font-sans: var(--app-font-sans);
--font-display: var(--app-font-display);
--font-serif: var(--app-font-serif);
--font-mono: var(--app-font-mono);
--paper: #faf9f7;
--bone: #f7f1de;
--surface: #ffffff;
--ink: #15140f;
--ink-soft: #2a2620;
--ink-mute: #5a5448;
--ink-faint: #8b8676;
--line: rgba(21, 20, 15, 0.16);
--line-soft: rgba(21, 20, 15, 0.08);
--line-faint: rgba(21, 20, 15, 0.05);
--coral: #c96442; /* editor rust */
--coral-warm: #ed6f5c; /* landing coral */
--coral-hover: #b25737;
--coral-soft: rgba(201, 100, 66, 0.12);
--mustard: #e9b94a;
--olive: #6e7448;
--green: #1f7a3a;
--blue: #2348b8;
--purple: #6c3aa6;
--amber: #b26200;
--red: #9c2a25;
color-scheme: light;
}
@theme inline {
--font-sans: var(--app-font-sans);
--font-display: var(--app-font-display);
--font-serif: var(--app-font-serif);
--font-mono: var(--app-font-mono);
--color-paper: var(--paper);
--color-bone: var(--bone);
--color-ink: var(--ink);
--color-ink-soft: var(--ink-soft);
--color-ink-mute: var(--ink-mute);
--color-ink-faint: var(--ink-faint);
--color-coral: var(--coral);
--color-line: var(--line);
}
html, body {
height: 100%;
font-family: var(--font-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-feature-settings: "ss01", "cv11";
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* paper grain — open-design's signature */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background:
radial-gradient(circle at 12% 18%, rgba(106, 92, 56, 0.07), transparent 45%),
radial-gradient(circle at 88% 72%, rgba(106, 92, 56, 0.05), transparent 50%);
}
/* serif italic for accent — open-design hero pattern */
.serif-em {
font-family: var(--font-serif);
font-style: italic;
font-weight: 500;
color: var(--coral);
}
/* eyebrow label */
.eyebrow {
font-family: var(--font-sans);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--coral);
position: relative;
padding-bottom: 4px;
}
.eyebrow::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 18px;
height: 1.5px;
background: var(--coral);
}
/* pulse dot */
.pulse-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--coral);
display: inline-block;
animation: od-pulse 2.4s ease-in-out infinite;
}
@keyframes od-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* od scrollbar */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--line-soft); border-radius: 8px; }
::-webkit-scrollbar-thumb:hover { background: var(--line); }
/* drop-zone hover */
.dropzone-active {
outline: 2px dashed var(--coral);
outline-offset: -8px;
background: var(--coral-soft);
}
/* shimmer */
@keyframes shimmer {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
.shimmer { animation: shimmer 1.6s ease-in-out infinite; }
/* fade-in for modal */
@keyframes od-fade-in {
from { opacity: 0; transform: translateY(6px) scale(0.985); }
to { opacity: 1; transform: none; }
}
.od-fade-in { animation: od-fade-in 0.25s cubic-bezier(0.2, 0.8, 0.3, 1) both; }
@keyframes od-backdrop {
from { opacity: 0; }
to { opacity: 1; }
}
.od-backdrop { animation: od-backdrop 0.2s ease-out both; }
/* ── reusable component primitives ─────────────────────────────── */
.btn-primary {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--coral);
color: #fff;
padding: 10px 20px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
border: none;
cursor: pointer;
box-shadow: 0 14px 26px -16px rgba(201, 100, 66, 0.85);
transition: all 0.18s ease;
}
.btn-primary:hover:not(:disabled) {
background: var(--coral-hover);
transform: translateY(-1px);
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
box-shadow: none;
}
.btn-ghost {
display: inline-flex;
align-items: center;
gap: 6px;
background: transparent;
color: var(--ink);
padding: 10px 18px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
border: 1px solid var(--line);
cursor: pointer;
transition: all 0.18s ease;
}
.btn-ghost:hover:not(:disabled) {
background: var(--line-faint);
border-color: var(--ink);
}
.btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-ink {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--ink);
color: var(--paper);
padding: 10px 20px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 0.18s ease;
}
.btn-ink:hover:not(:disabled) { background: var(--ink-soft); transform: translateY(-1px); }
.btn-ink:disabled { opacity: 0.4; cursor: not-allowed; }
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--surface);
font-size: 12px;
color: var(--ink-soft);
font-weight: 500;
}
.pill-active {
background: var(--ink);
color: var(--paper);
border-color: var(--ink);
}
.od-card {
background: var(--surface);
border: 1px solid var(--line-soft);
border-radius: 16px;
box-shadow: 0 1px 0 var(--line-faint), 0 18px 40px -28px rgba(21, 20, 15, 0.16);
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "HTML Anything — the agentic HTML editor",
description:
"Markdown is the draft; HTML is what humans read. Your local AI agent writes HTML directly — decks, resumes, posters, knowledge cards, data reports, Hyperframes videos — one click to WeChat / X / Zhihu.",
metadataBase: new URL("https://html-anything.app"),
openGraph: {
title: "HTML Anything — the agentic HTML editor",
description: "Markdown is the draft. HTML is what humans read. Your local agent writes it.",
type: "website",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="h-full antialiased" suppressHydrationWarning>
<body
className="min-h-full bg-[var(--paper)] text-[var(--ink)] selection:bg-[var(--coral)]/30"
suppressHydrationWarning
>
{children}
</body>
</html>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Toolbar } from "@/components/toolbar";
import { EditorPane } from "@/components/editor-pane";
import { PreviewPane } from "@/components/preview-pane";
import { TasksSidebar } from "@/components/tasks-sidebar";
import { HistoryPane } from "@/components/history-pane";
import { WelcomeModal } from "@/components/welcome-modal";
import { SettingsModal, type SectionId } from "@/components/settings-modal";
import { ConvertChip } from "@/components/convert-chip";
import { useStore, type AgentInfo } from "@/lib/store";
export default function Home() {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const welcomeAck = useStore((s) => s.welcomeAck);
const selectedAgent = useStore((s) => s.selectedAgent);
const setAgents = useStore((s) => s.setAgents);
const locale = useStore((s) => s.locale);
const layoutMode = useStore((s) => s.layoutMode);
const [welcomeOpen, setWelcomeOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] = useState<
SectionId | undefined
>(undefined);
const [deployConfigRev, setDeployConfigRev] = useState(0);
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
setHydrated(true);
}, []);
// Detect agents on mount so the toolbar's agent chip can resolve the
// persisted `selectedAgent` to a label without waiting for the user to
// open Settings or Welcome. Without this, after a hard reload the chip
// briefly (or permanently) shows "Select agent" even though selection
// is intact in localStorage.
useEffect(() => {
if (!hydrated) return;
let cancelled = false;
(async () => {
try {
const res = await fetch("/api/agents", { cache: "no-store" });
if (!res.ok) return;
const data = (await res.json()) as { agents: AgentInfo[] };
if (!cancelled) setAgents(data.agents);
} catch {
// Settings / Welcome modals will retry on open.
}
})();
return () => {
cancelled = true;
};
}, [hydrated, setAgents]);
// Keep <html lang="…"> in sync with the user's locale so screen readers
// and browser features (autotranslate, hyphenation) pick the right language.
useEffect(() => {
if (typeof document !== "undefined") {
document.documentElement.setAttribute("lang", locale);
}
}, [locale]);
useEffect(() => {
if (!hydrated) return;
if (!welcomeAck || !selectedAgent) setWelcomeOpen(true);
}, [hydrated, welcomeAck, selectedAgent]);
return (
<main className="relative flex h-screen flex-col">
<Toolbar
iframeRef={iframeRef}
onOpenAgentPicker={() => setSettingsOpen(true)}
onOpenSettings={() => setSettingsOpen(true)}
onRequestConfigureDeploy={() => {
setSettingsInitialSection("deploy");
setSettingsOpen(true);
}}
deployConfigRev={deployConfigRev}
/>
<div
className="flex flex-1 min-h-0"
style={{ borderTop: "1px solid var(--line-faint)" }}
>
<TasksSidebar />
<HistoryPane />
<div className="relative flex flex-1 min-w-0">
{layoutMode !== "preview" && (
<section
className="flex min-w-0 flex-1 basis-0 flex-col"
style={
layoutMode === "split"
? { borderRight: "1px solid var(--line-faint)" }
: undefined
}
>
<EditorPane />
</section>
)}
{layoutMode !== "editor" && (
<section className="flex min-w-0 flex-1 basis-0 flex-col">
<PreviewPane iframeRef={iframeRef} />
</section>
)}
<ConvertChip />
</div>
</div>
{welcomeOpen && <WelcomeModal onClose={() => setWelcomeOpen(false)} />}
{settingsOpen && (
<SettingsModal
initialSection={settingsInitialSection}
onClose={() => {
setSettingsOpen(false);
setSettingsInitialSection(undefined);
setDeployConfigRev((r) => r + 1);
}}
/>
)}
</main>
);
}
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { useRef, useState } from "react";
import { useStore } from "@/lib/store";
import { useT } from "@/lib/i18n";
import { useDraft } from "@/lib/use-draft";
import { useUploadFile } from "@/lib/use-upload";
const ACCEPT_TYPES =
".md,.txt,.pdf,.csv,.tsv,.xlsx,.xls,.json,.sql,.yaml,.yml,.png,.jpg,.jpeg,.gif,.webp,.svg,.html,.htm,.xml,.log";
/**
* Sticky one-line input pinned to the bottom of the editor's Text tab. The
* user types a natural-language instruction ("write a tweet about X"); the
* selected agent streams a markdown draft that's appended below the current
* editor content. The 📎 button on the same row reuses the same upload
* pipeline as the textarea drag-and-drop target, so users can attach a file
* without leaving this row. Convert (HTML output) is unaffected — Draft is
* a separate /api/draft endpoint that prompts the agent for plain markdown.
*/
export function AiPromptBar() {
const [value, setValue] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const agent = useStore((s) => s.selectedAgent);
const { run, cancel, status, error } = useDraft();
const { ingest } = useUploadFile();
const t = useT();
const isRunning = status === "running";
const canSubmit = !!agent && !!value.trim() && !isRunning;
const onSubmit = () => {
if (isRunning) {
cancel();
return;
}
if (!canSubmit) return;
const instruction = value.trim();
setValue("");
run({ instruction });
};
return (
<div
className="border-t px-3 py-2"
style={{
borderColor: "var(--line-faint)",
background: "var(--paper)",
}}
>
<div className="flex items-center gap-2">
<span aria-hidden className="shrink-0 text-[14px] opacity-60"></span>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
onSubmit();
}
}}
placeholder={agent ? t("aiPrompt.placeholder") : t("aiPrompt.needAgent")}
disabled={!agent || isRunning}
className="min-w-0 flex-1 rounded-full px-3.5 py-1.5 text-[12.5px] outline-none disabled:cursor-not-allowed disabled:opacity-60"
style={{
background: "var(--surface)",
border: "1px solid var(--line)",
color: "var(--ink)",
}}
/>
<button
onClick={() => fileInputRef.current?.click()}
className="shrink-0 inline-flex items-center gap-1 rounded-full px-2.5 py-1.5 text-[12px] text-[var(--ink-mute)] transition-colors hover:bg-[var(--surface)] hover:text-[var(--ink)]"
title={t("editor.attachTooltip")}
>
<span aria-hidden>📎</span>
<span className="hidden text-[11.5px] sm:inline">{t("editor.attach")}</span>
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
accept={ACCEPT_TYPES}
onChange={(e) => {
ingest(e.target.files);
e.target.value = "";
}}
/>
<button
onClick={onSubmit}
disabled={!isRunning && !canSubmit}
className="shrink-0 rounded-full px-3 py-1.5 text-[11.5px] font-medium transition-all disabled:cursor-not-allowed disabled:opacity-40"
style={{
background: isRunning ? "var(--coral)" : "var(--ink)",
color: "#fff",
border: "1px solid transparent",
}}
title={t("aiPrompt.hint")}
>
{isRunning ? t("aiPrompt.stop") : t("aiPrompt.submit")}
<span className="ml-1.5 hidden text-[10px] opacity-60 sm:inline"></span>
</button>
</div>
{error && (
<div className="mt-1 text-[11px]" style={{ color: "var(--red)" }}>
{error}
</div>
)}
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { useCallback, useEffect } from "react";
import { useStore, selectActiveTask } from "@/lib/store";
import { useT } from "@/lib/i18n";
import { useConvert } from "@/lib/use-convert";
/**
* Floating chip pinned to the editor / preview divider — the single Convert
* entry point. The toolbar no longer has its own Convert button (removed to
* avoid duplication), so this chip also owns the ⌘/Ctrl+Enter shortcut.
*/
export function ConvertChip() {
const agent = useStore((s) => s.selectedAgent);
const agents = useStore((s) => s.agents);
const agentModels = useStore((s) => s.agentModels);
const activeTaskId = useStore((s) => s.activeTaskId);
const template = useStore((s) => selectActiveTask(s)?.templateId ?? "article-magazine");
const content = useStore((s) => selectActiveTask(s)?.content ?? "");
const format = useStore((s) => selectActiveTask(s)?.format ?? "text");
const status = useStore((s) => selectActiveTask(s)?.status ?? "idle");
const layoutMode = useStore((s) => s.layoutMode);
const { run, cancel } = useConvert();
const t = useT();
const agentInfo = agents.find((a) => a.id === agent);
const model = agent ? agentModels[agent] ?? "default" : "default";
const canConvert =
!!agent && !!content.trim() && status !== "running" && !agentInfo?.unsupported;
const isRunning = status === "running";
const tip = !agent
? t("toolbar.firstSelectAgent")
: agentInfo?.unsupported
? t("toolbar.unsupportedProtocol")
: !content.trim()
? t("toolbar.enterContent")
: t("convertChip.tooltip");
const onClick = useCallback(() => {
if (isRunning) {
cancel(activeTaskId);
return;
}
if (!canConvert) return;
run({ taskId: activeTaskId, agent: agent!, templateId: template, content, format, model });
}, [isRunning, canConvert, cancel, run, activeTaskId, agent, template, content, format, model]);
// ⌘/Ctrl + Enter — global shortcut, fires Convert from anywhere on the page.
// Lives here (not in Toolbar) because the chip is the single source of
// Convert truth after the toolbar button was removed.
useEffect(() => {
if (layoutMode !== "split") return;
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
if (isRunning || !canConvert) return;
e.preventDefault();
onClick();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [layoutMode, onClick, isRunning, canConvert]);
// Only show in split mode — when only one pane is visible there's no
// divider to hang off, and the toolbar button is already obvious.
if (layoutMode !== "split") return null;
return (
<div
className="pointer-events-none absolute inset-y-0 left-1/2 z-30 flex items-center"
style={{ transform: "translateX(-50%)" }}
>
<button
onClick={onClick}
disabled={!isRunning && !canConvert}
title={tip}
aria-label={t("convertChip.label")}
className="pointer-events-auto group relative flex items-center gap-2 rounded-full px-4 py-2.5 text-[12.5px] font-medium shadow-lg transition-all hover:scale-[1.02] active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40"
style={{
background: isRunning ? "var(--coral-hover)" : "var(--coral)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.18)",
boxShadow: "0 4px 18px rgba(201, 100, 66, 0.32)",
}}
>
{isRunning ? (
<>
<span className="pulse-dot" style={{ background: "#fff" }} />
{t("toolbar.stop")}
</>
) : (
<>
<span aria-hidden></span>
{t("convertChip.label")}
<span className="hidden text-[10.5px] opacity-70 sm:inline"></span>
</>
)}
</button>
</div>
);
}
+295
View File
@@ -0,0 +1,295 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { parseDeck, type DeckSlide } from "@/lib/deck";
import { useT } from "@/lib/i18n";
type Props = {
html: string;
/** When `true`, the deck viewer is the visible tab — it can grab keyboard events. */
active: boolean;
/** Bubble out the latest main-slide iframe so screenshot/export can target it. */
onMainIframe?: (el: HTMLIFrameElement | null) => void;
/** Bubble out the parsed slides so the export menu can iterate them. */
onSlides?: (slides: DeckSlide[]) => void;
};
export function DeckViewer({ html, active, onMainIframe, onSlides }: Props) {
const t = useT();
const parsed = useMemo(() => parseDeck(html), [html]);
const slides = parsed.slides;
const [index, setIndex] = useState(0);
const [showNotes, setShowNotes] = useState(false);
const wrapRef = useRef<HTMLDivElement | null>(null);
const mainIframeRef = useRef<HTMLIFrameElement | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
// Keep index in range when the deck shrinks (e.g. live re-render).
useEffect(() => {
if (index >= slides.length) setIndex(Math.max(0, slides.length - 1));
}, [slides.length, index]);
// Bubble the parsed slides up so the export menu can use them.
useEffect(() => {
onSlides?.(slides);
}, [slides, onSlides]);
// Bubble main iframe up so screenshot can re-use the existing helpers.
useEffect(() => {
onMainIframe?.(mainIframeRef.current);
});
const next = useCallback(() => setIndex((i) => Math.min(slides.length - 1, i + 1)), [slides.length]);
const prev = useCallback(() => setIndex((i) => Math.max(0, i - 1)), []);
// Keyboard nav (only when this tab is the active one — otherwise the
// editor textarea would steal arrow keys, but we only listen for ←/→ on the
// wrapper and on document while in fullscreen).
useEffect(() => {
if (!active) return;
const onKey = (e: KeyboardEvent) => {
if (e.target instanceof HTMLElement) {
const tag = e.target.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || e.target.isContentEditable) return;
}
if (e.key === "ArrowRight" || e.key === "PageDown" || e.key === " ") { e.preventDefault(); next(); }
else if (e.key === "ArrowLeft" || e.key === "PageUp") { e.preventDefault(); prev(); }
else if (e.key === "Home") { e.preventDefault(); setIndex(0); }
else if (e.key === "End") { e.preventDefault(); setIndex(slides.length - 1); }
else if (e.key === "f" || e.key === "F") { e.preventDefault(); enterFullscreen(); }
else if (e.key === "n" || e.key === "N") { e.preventDefault(); setShowNotes((v) => !v); }
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [active, next, prev, slides.length]);
// Track browser-level fullscreen state.
useEffect(() => {
const onFs = () => setIsFullscreen(document.fullscreenElement === wrapRef.current);
document.addEventListener("fullscreenchange", onFs);
return () => document.removeEventListener("fullscreenchange", onFs);
}, []);
const enterFullscreen = useCallback(() => {
const el = wrapRef.current;
if (!el) return;
if (document.fullscreenElement === el) {
document.exitFullscreen?.();
} else {
el.requestFullscreen?.().catch(() => {});
}
}, []);
if (slides.length === 0) {
return (
<div className="grid h-full place-items-center text-[13px] text-[var(--ink-mute)]">
{t("deck.empty")}
</div>
);
}
const current = slides[index];
return (
<div
ref={wrapRef}
className="relative flex h-full w-full flex-col"
style={{ background: isFullscreen ? "#0a0a0a" : "var(--paper)" }}
>
{/* main slide canvas */}
<div className="relative flex-1 overflow-hidden">
<iframe
key={current.id /* force-reload between slides for clean keyboard focus */}
ref={mainIframeRef}
title={`slide-${current.id}`}
srcDoc={current.html}
sandbox="allow-scripts allow-same-origin"
className="h-full w-full"
style={{ background: current.bg ?? "#fff", border: "0" }}
/>
{/* floating prev/next arrows */}
{slides.length > 1 && (
<>
<button
onClick={prev}
disabled={index === 0}
aria-label={t("deck.prev")}
className="absolute left-3 top-1/2 -translate-y-1/2 grid h-10 w-10 place-items-center rounded-full text-[18px] disabled:opacity-30 transition-opacity"
style={{
background: isFullscreen ? "rgba(255,255,255,0.10)" : "var(--surface)",
color: isFullscreen ? "#fff" : "var(--ink)",
border: isFullscreen ? "1px solid rgba(255,255,255,0.18)" : "1px solid var(--line)",
boxShadow: isFullscreen ? "none" : "0 4px 14px -4px rgba(0,0,0,0.18)",
}}
>
</button>
<button
onClick={next}
disabled={index === slides.length - 1}
aria-label={t("deck.next")}
className="absolute right-3 top-1/2 -translate-y-1/2 grid h-10 w-10 place-items-center rounded-full text-[18px] disabled:opacity-30 transition-opacity"
style={{
background: isFullscreen ? "rgba(255,255,255,0.10)" : "var(--surface)",
color: isFullscreen ? "#fff" : "var(--ink)",
border: isFullscreen ? "1px solid rgba(255,255,255,0.18)" : "1px solid var(--line)",
boxShadow: isFullscreen ? "none" : "0 4px 14px -4px rgba(0,0,0,0.18)",
}}
>
</button>
</>
)}
{/* top-right toolbar — Present + Notes */}
<div className="absolute right-3 top-3 flex items-center gap-1.5">
{current.notes && (
<button
onClick={() => setShowNotes((v) => !v)}
className="rounded-full px-3 py-1.5 text-[11px] font-medium"
style={{
background: showNotes
? "var(--ink)"
: isFullscreen ? "rgba(255,255,255,0.10)" : "var(--surface)",
color: showNotes
? "var(--paper)"
: isFullscreen ? "#fff" : "var(--ink)",
border: isFullscreen
? "1px solid rgba(255,255,255,0.18)"
: "1px solid var(--line)",
}}
title={t("deck.notesTooltip")}
>
{t("deck.notes")} <span className="opacity-60">N</span>
</button>
)}
<button
onClick={enterFullscreen}
className="rounded-full px-3 py-1.5 text-[11px] font-medium"
style={{
background: isFullscreen ? "rgba(255,255,255,0.10)" : "var(--ink)",
color: isFullscreen ? "#fff" : "var(--paper)",
border: isFullscreen ? "1px solid rgba(255,255,255,0.18)" : "1px solid var(--ink)",
}}
title={t("deck.presentTooltip")}
>
{isFullscreen ? t("deck.exitPresent") : t("deck.present")}
<span className="ml-1 opacity-60">F</span>
</button>
</div>
{/* page count badge — bottom-right of canvas */}
<div
className="absolute bottom-3 right-3 rounded-full px-3 py-1 text-[11px] font-medium tabular-nums"
style={{
background: isFullscreen ? "rgba(255,255,255,0.12)" : "var(--surface)",
color: isFullscreen ? "#fff" : "var(--ink-soft)",
border: isFullscreen ? "1px solid rgba(255,255,255,0.18)" : "1px solid var(--line)",
fontFamily: "var(--font-mono)",
}}
>
{index + 1} / {slides.length}
</div>
{/* speaker notes overlay */}
{showNotes && current.notes && (
<div
className="absolute inset-x-3 bottom-14 max-h-[40%] overflow-auto rounded-2xl p-4 text-[13px] leading-relaxed"
style={{
background: isFullscreen ? "rgba(20,20,20,0.92)" : "var(--surface)",
color: isFullscreen ? "#f5f5f5" : "var(--ink)",
border: isFullscreen ? "1px solid rgba(255,255,255,0.10)" : "1px solid var(--line)",
backdropFilter: "blur(14px)",
boxShadow: "0 24px 60px -20px rgba(0,0,0,0.40)",
}}
>
<div
className="mb-2 text-[10px] uppercase tracking-[0.18em]"
style={{ color: isFullscreen ? "rgba(255,255,255,0.55)" : "var(--ink-faint)" }}
>
{t("deck.notes")} · {t("deck.slideN", { n: index + 1, m: slides.length })}
</div>
<p className="whitespace-pre-wrap">{current.notes}</p>
</div>
)}
</div>
{/* thumbnail strip */}
{!isFullscreen && (
<ThumbStrip
slides={slides}
activeIndex={index}
onPick={setIndex}
/>
)}
</div>
);
}
function ThumbStrip({
slides,
activeIndex,
onPick,
}: {
slides: DeckSlide[];
activeIndex: number;
onPick: (i: number) => void;
}) {
// Each thumb is a 200×112 (16:9) iframe scaled down — heavy if there are
// many slides, so we render at most ~80; users with bigger decks scroll.
return (
<div
className="flex gap-2 overflow-x-auto px-3 py-3"
style={{ borderTop: "1px solid var(--line-faint)", background: "var(--surface)" }}
>
{slides.map((s, i) => {
const active = i === activeIndex;
return (
<button
key={s.id + ":" + i}
onClick={() => onPick(i)}
className="relative shrink-0 overflow-hidden rounded-lg transition-all"
style={{
width: 168,
height: 96,
background: s.bg ?? "#fff",
border: active ? "2px solid var(--ink)" : "1px solid var(--line)",
boxShadow: active ? "0 6px 18px -8px rgba(21,20,15,0.28)" : "none",
}}
title={`Slide ${i + 1}`}
>
<iframe
title={`thumb-${s.id}`}
srcDoc={s.html}
sandbox="allow-same-origin"
scrolling="no"
tabIndex={-1}
aria-hidden="true"
style={{
pointerEvents: "none",
// Render at slide native width then visually shrink — avoids
// the iframe trying to mobile-reflow Tailwind utility text.
width: 1920,
height: 1080,
border: 0,
transform: `scale(${168 / 1920})`,
transformOrigin: "top left",
}}
/>
<span
className="absolute bottom-1 left-1 rounded px-1.5 py-0.5 text-[9px] tabular-nums"
style={{
background: "rgba(0,0,0,0.55)",
color: "#fff",
fontFamily: "var(--font-mono)",
}}
>
{i + 1}
</span>
</button>
);
})}
</div>
);
}
+344
View File
@@ -0,0 +1,344 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useDeploy } from "@/lib/use-deploy";
import { useT } from "@/lib/i18n";
import {
useStore,
selectActiveTask,
type DeploymentRecord,
} from "@/lib/store";
const EMPTY_DEPLOYMENTS: readonly DeploymentRecord[] = [];
/**
* Publish-to-Vercel control rendered next to the preview-pane toolbar
* actions. Renders three things in one rounded popover-anchor block:
*
* 1. The Publish / Deploying… / coral pill — primary CTA. Disabled when
* the task has no html yet.
* 2. A compact result line — "Live at <url> [Copy] [Open]" — appearing
* immediately below the button after a successful deploy. Persists
* until the user navigates away from the task.
* 3. A "Past deployments" dropdown listing the bounded ring (latest 5
* entries, hash-tagged so the user can tell which version of the html
* each url corresponds to).
*
* Cloudflare Pages provider is intentionally absent until the wasm-blake3
* dependency lands in a follow-up PR — Settings → Deploy already shows a
* "coming soon" placeholder for it.
*/
export function DeployControl({
onRequestConfigureDeploy,
configRev = 0,
}: {
onRequestConfigureDeploy: () => void;
configRev?: number;
}) {
const html = useStore((s) => selectActiveTask(s)?.html ?? "");
const taskId = useStore((s) => s.activeTaskId);
const deployments = useStore(
(s) => selectActiveTask(s)?.deployments ?? EMPTY_DEPLOYMENTS,
);
const removeDeploymentFor = useStore((s) => s.removeDeploymentFor);
const t = useT();
const { status, error, latest, deploy } = useDeploy();
const [historyOpen, setHistoryOpen] = useState(false);
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
const [dismissedLatestId, setDismissedLatestId] = useState<string | null>(
null,
);
// null = unknown (still loading), true/false = checked state. Probed via
// the same /api/deploy/config endpoint Settings → Deploy reads, so the
// toolbar Publish button knows whether to deploy or steer the user to
// configure a token first.
const [configured, setConfigured] = useState<boolean | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
fetch("/api/deploy/config?provider=vercel")
.then((r) => r.json())
.then((d: { configured?: boolean }) => {
if (!cancelled) setConfigured(!!d?.configured);
})
.catch(() => {
if (!cancelled) setConfigured(false);
});
return () => {
cancelled = true;
};
}, [configRev]);
// Close the history dropdown on outside click.
useEffect(() => {
if (!historyOpen) return;
const onClick = (e: MouseEvent) => {
if (!popoverRef.current?.contains(e.target as Node)) {
setHistoryOpen(false);
}
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, [historyOpen]);
// Auto-clear the "Copied" feedback after 1.5 s.
useEffect(() => {
if (!copiedUrl) return;
const id = setTimeout(() => setCopiedUrl(null), 1500);
return () => clearTimeout(id);
}, [copiedUrl]);
const isDeploying = status === "deploying";
const isUnconfigured = configured === false;
// The button is clickable in two cases: a normal deploy (configured + has html),
// or an unconfigured-state click that steers the user to Settings. Anything else
// (deploying, or configured-but-no-html) disables the button.
const disabled = isDeploying || (!isUnconfigured && html.length === 0);
const onClickPublish = () => {
if (isDeploying) return;
if (isUnconfigured) {
onRequestConfigureDeploy();
return;
}
if (html.length === 0) return;
void deploy({ taskId, provider: "vercel", html });
};
const onCopy = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
setCopiedUrl(url);
} catch {
/* clipboard may be denied — surface nothing, the URL is still visible */
}
};
// Newest record first; `latest` may be the same as `deployments[0]` so we
// dedupe by id to avoid rendering it twice in the result line + history.
const visibleLatest =
latest && latest.id !== dismissedLatestId ? latest : null;
const visibleHistory = deployments.filter((d) => d.id !== visibleLatest?.id);
return (
<div ref={popoverRef} className="relative inline-flex items-center gap-1.5">
<button
onClick={onClickPublish}
disabled={disabled}
title={disabled ? t("deploy.button.disabled") : undefined}
className="btn-ink"
style={isDeploying ? { background: "var(--coral)" } : undefined}
>
{isDeploying ? (
<>
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-white align-middle" />
{t("deploy.deploying")}
</>
) : (
<>📤 {t("deploy.button")}</>
)}
</button>
{deployments.length > 0 && (
<button
onClick={() => setHistoryOpen((o) => !o)}
className="rounded-full px-2 py-0.5 text-[11px]"
title={t("deploy.history.title")}
style={{
background: "transparent",
color: "var(--ink-soft)",
border: "1px solid var(--line)",
}}
>
<span className="tabular-nums">{deployments.length}</span>
</button>
)}
{/* Inline result row — shown right after a successful deploy. */}
{visibleLatest && (
<div
className="absolute right-0 top-[calc(100%+6px)] z-30 flex max-w-[420px] flex-col gap-1 rounded-xl px-3 py-2 text-[11px] shadow-lg"
style={{
background: "var(--paper)",
border: "1px solid var(--line-soft)",
color: "var(--ink)",
}}
>
<div className="flex items-center gap-1.5">
<span
className="h-1.5 w-1.5 rounded-full"
style={{
background:
visibleLatest.status === "ready"
? "var(--green)"
: visibleLatest.status === "protected"
? "var(--coral)"
: "var(--amber, #d97706)",
}}
/>
<span className="font-medium">
{visibleLatest.status === "ready"
? t("deploy.success.label")
: visibleLatest.status === "protected"
? t("deploy.protected.label")
: t("deploy.delayed.label")}
</span>
<button
onClick={() => setDismissedLatestId(visibleLatest.id)}
aria-label={t("deploy.success.dismiss")}
title={t("deploy.success.dismiss")}
className="ml-auto inline-flex h-5 w-5 items-center justify-center rounded-full text-[12px] hover:bg-[var(--surface)]"
style={{ color: "var(--ink-mute)" }}
>
x
</button>
</div>
<a
href={visibleLatest.url}
target="_blank"
rel="noreferrer noopener"
className="block max-w-full truncate font-mono text-[11px] text-[var(--coral)] hover:underline"
title={visibleLatest.url}
>
{visibleLatest.url}
</a>
{visibleLatest.status !== "ready" && visibleLatest.statusMessage && (
<div className="text-[10.5px] text-[var(--ink-mute)] leading-snug">
{visibleLatest.statusMessage}
</div>
)}
<div className="mt-0.5 flex items-center gap-1.5">
<button
onClick={() => onCopy(visibleLatest.url)}
className="rounded-full px-2 py-0.5 text-[10.5px] hover:bg-[var(--surface)]"
style={{
background: "transparent",
color: "var(--ink-soft)",
border: "1px solid var(--line)",
}}
>
{copiedUrl === visibleLatest.url
? t("deploy.success.copied")
: `📋 ${t("deploy.success.copy")}`}
</button>
<a
href={visibleLatest.url}
target="_blank"
rel="noreferrer noopener"
className="rounded-full px-2 py-0.5 text-[10.5px] no-underline hover:bg-[var(--surface)]"
style={{
background: "transparent",
color: "var(--ink-soft)",
border: "1px solid var(--line)",
}}
>
{t("deploy.success.open")}
</a>
</div>
</div>
)}
{/* History dropdown — past deployments other than `latest`. */}
{historyOpen && visibleHistory.length > 0 && (
<div
className="absolute right-0 top-[calc(100%+6px)] z-40 flex w-[320px] flex-col rounded-xl px-2 py-2 shadow-lg"
style={{
background: "var(--paper)",
border: "1px solid var(--line-soft)",
}}
>
<div className="px-2 py-1 text-[10.5px] uppercase tracking-[0.16em] text-[var(--ink-faint)]">
{t("deploy.history.title")}
</div>
<div className="flex flex-col gap-0.5">
{visibleHistory.map((d) => (
<HistoryRow
key={d.id}
d={d}
onForget={() => removeDeploymentFor(taskId, d.id)}
onCopy={() => onCopy(d.url)}
copied={copiedUrl === d.url}
t={t}
/>
))}
</div>
</div>
)}
{error && status === "error" && (
<div
className="absolute right-0 top-[calc(100%+6px)] z-30 max-w-[420px] rounded-xl px-3 py-2 text-[11px] shadow-lg"
style={{
background: "var(--paper)",
border: "1px solid var(--coral)",
color: "var(--coral)",
}}
>
<div className="font-medium">{t("deploy.error.label")}</div>
<div className="mt-1 text-[10.5px] text-[var(--ink-mute)] leading-snug">
{error}
</div>
</div>
)}
</div>
);
}
function HistoryRow({
d,
onForget,
onCopy,
copied,
t,
}: {
d: DeploymentRecord;
onForget: () => void;
onCopy: () => void;
copied: boolean;
t: ReturnType<typeof useT>;
}) {
const ts = new Date(d.deployedAt);
const stamp = `${ts.getMonth() + 1}/${ts.getDate()} ${String(ts.getHours()).padStart(2, "0")}:${String(ts.getMinutes()).padStart(2, "0")}`;
return (
<div
className="group flex flex-col gap-0.5 rounded-lg px-2 py-1.5 hover:bg-[var(--surface)]"
>
<a
href={d.url}
target="_blank"
rel="noreferrer noopener"
className="block truncate font-mono text-[11px] text-[var(--coral)] hover:underline"
title={d.url}
>
{d.url}
</a>
<div className="flex items-center gap-2 text-[10px] text-[var(--ink-faint)]">
<span>{d.provider}</span>
<span>·</span>
<span>{stamp}</span>
{d.htmlHash && (
<>
<span>·</span>
<span className="font-mono">#{d.htmlHash.slice(0, 6)}</span>
</>
)}
<div className="ml-auto flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={onCopy}
className="rounded px-1.5 py-0.5 text-[10px] hover:bg-[var(--paper)]"
>
{copied ? t("deploy.success.copied") : t("deploy.success.copy")}
</button>
<button
onClick={onForget}
className="rounded px-1.5 py-0.5 text-[10px] text-[var(--ink-mute)] hover:bg-[var(--paper)] hover:text-[var(--coral)]"
>
{t("deploy.history.delete")}
</button>
</div>
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { listDrafts, deleteDraft, type Draft } from "@/lib/drafts";
import { relativeTime } from "@/lib/use-autosave";
import { useStore } from "@/lib/store";
import { useT } from "@/lib/i18n";
export function DraftsMenu() {
const [open, setOpen] = useState(false);
const [drafts, setDrafts] = useState<Draft[]>([]);
const ref = useRef<HTMLDivElement>(null);
const setContent = useStore((s) => s.setContent);
const setFormat = useStore((s) => s.setFormat);
const setFilename = useStore((s) => s.setFilename);
const pushLog = useStore((s) => s.pushLog);
const t = useT();
const refresh = () => setDrafts(listDrafts());
useEffect(() => {
if (open) refresh();
}, [open]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (open && ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const restore = (d: Draft) => {
if (!confirm(t("drafts.restoreConfirm", { when: relativeTime(d.ts) }))) return;
setContent(d.content);
setFormat(d.format);
setFilename(d.filename);
pushLog({
kind: "info",
text: t("drafts.restoredLog", { when: relativeTime(d.ts), n: d.bytes.toLocaleString() }),
});
setOpen(false);
};
const remove = (e: React.MouseEvent, ts: number) => {
e.stopPropagation();
deleteDraft(ts);
refresh();
};
return (
<div className="relative shrink-0" ref={ref}>
<button
onClick={() => setOpen((o) => !o)}
className="whitespace-nowrap text-[10px] uppercase tracking-[0.14em] text-[var(--ink-faint)] hover:text-[var(--ink)] transition-colors px-1.5 py-0.5"
title={t("drafts.tooltip")}
>
{t("drafts.button")}
</button>
{open && (
<div
className="absolute right-0 top-full mt-1.5 z-30 w-[360px] od-fade-in overflow-hidden rounded-2xl"
style={{
background: "var(--surface)",
border: "1px solid var(--line-soft)",
boxShadow: "0 30px 60px -20px rgba(21, 20, 15, 0.25)",
}}
>
<div
className="px-4 pt-3 pb-2 text-[10px] uppercase tracking-[0.18em] text-[var(--ink-faint)] flex items-center justify-between"
style={{ borderBottom: "1px solid var(--line-faint)" }}
>
<span>{t("drafts.heading")}</span>
<span className="font-mono text-[var(--ink-mute)] normal-case tracking-normal">
{t("drafts.count", { n: drafts.length })}
</span>
</div>
{drafts.length === 0 ? (
<div className="px-4 py-6 text-center text-[12px] text-[var(--ink-mute)]">
{t("drafts.empty.title")}
<div className="text-[11px] text-[var(--ink-faint)] mt-1">{t("drafts.empty.hint")}</div>
</div>
) : (
<div className="max-h-[60vh] overflow-y-auto p-1.5">
{drafts.map((d) => (
<button
key={d.ts}
onClick={() => restore(d)}
className="group w-full flex items-start gap-3 rounded-xl px-3 py-2 text-left hover:bg-[var(--paper)]"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[12px]">
<span className="font-medium text-[var(--ink)]">{relativeTime(d.ts)}</span>
<span className="text-[10px] text-[var(--ink-faint)]">·</span>
<span className="text-[10.5px] font-mono text-[var(--ink-mute)]">
{d.format} · {t("drafts.chars", { n: d.bytes.toLocaleString() })}
</span>
</div>
<div className="text-[11px] text-[var(--ink-mute)] mt-0.5 line-clamp-1">
{d.preview || t("drafts.emptyPreview")}
</div>
</div>
<span
onClick={(e) => remove(e, d.ts)}
className="opacity-0 group-hover:opacity-60 hover:!opacity-100 text-[var(--ink-faint)] hover:text-[var(--red)] transition-opacity text-xs px-1"
title={t("drafts.delete")}
>
</span>
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
+206
View File
@@ -0,0 +1,206 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { useStore, selectActiveTask, usePersistHydrated } from "@/lib/store";
import { detectFormat } from "@/lib/parsers/auto";
import { useAutosave, relativeTime } from "@/lib/use-autosave";
import { downloadMarkdown } from "@/lib/export/download";
import { useUploadFile } from "@/lib/use-upload";
import { useT, type DictKey } from "@/lib/i18n";
import { DraftsMenu } from "./drafts-menu";
import { SamplesGallery } from "./samples-gallery";
import { FormatsGallery } from "./formats-gallery";
import { AiPromptBar } from "./ai-prompt-bar";
const TAB_KEY: Record<"text" | "formats" | "samples", DictKey> = {
text: "editor.tab.text",
formats: "editor.tab.formats",
samples: "editor.tab.samples",
};
export function EditorPane() {
const [tab, setTab] = useState<"text" | "formats" | "samples">("text");
const [dragActive, setDragActive] = useState(false);
const hydrated = usePersistHydrated();
const content = useStore((s) => selectActiveTask(s)?.content ?? "");
const setContent = useStore((s) => s.setContent);
const format = useStore((s) => selectActiveTask(s)?.format ?? "text");
const setFormat = useStore((s) => s.setFormat);
const setFilename = useStore((s) => s.setFilename);
const { status: saveStatus, savedAt } = useAutosave();
const { ingest } = useUploadFile();
const t = useT();
const detected = useMemo(() => detectFormat(content), [content]);
const hasContent = content.length > 0;
return (
<div className="flex h-full flex-col" style={{ background: "var(--surface)" }}>
<div
className="flex flex-col gap-1.5 px-4 py-2 text-sm"
style={{ borderBottom: "1px solid var(--line-faint)" }}
>
<div className="flex items-center justify-between gap-3">
<div className="flex shrink-0 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{(["text", "formats", "samples"] as const).map((id) => (
<button
key={id}
onClick={() => setTab(id)}
className={
(tab === id ? "pill pill-active" : "pill") + " whitespace-nowrap"
}
style={tab === id ? undefined : { background: "transparent", border: "1px solid transparent" }}
>
{t(TAB_KEY[id])}
</button>
))}
</div>
{hasContent && (
<SaveIndicator status={saveStatus} savedAt={savedAt} hasContent={hasContent} />
)}
</div>
{hasContent && (
<div className="flex flex-wrap items-center justify-end gap-x-2.5 gap-y-1 whitespace-nowrap text-[11px] text-[var(--ink-faint)]">
<button
onClick={() => downloadMarkdown(content, "draft")}
className="shrink-0 whitespace-nowrap text-[10px] uppercase tracking-[0.14em] text-[var(--ink-faint)] hover:text-[var(--ink)] transition-colors"
title={t("editor.backupTooltip")}
>
{t("editor.backup")}
</button>
<DraftsMenu />
<span className="shrink-0 opacity-40">|</span>
<span className="shrink-0 whitespace-nowrap">
<code className="font-mono text-[var(--ink-soft)]">{format || detected}</code>
<span className="mx-1 opacity-40">·</span>
{t("editor.chars", { n: content.length.toLocaleString() })}
</span>
</div>
)}
</div>
<div className="relative flex-1 overflow-hidden">
{tab === "text" && (
<div
className="flex h-full flex-col"
onDragEnter={(e) => {
if (e.dataTransfer?.types?.includes("Files")) {
e.preventDefault();
setDragActive(true);
}
}}
onDragOver={(e) => {
if (e.dataTransfer?.types?.includes("Files")) {
e.preventDefault();
}
}}
onDragLeave={(e) => {
// Only deactivate when leaving the outer container, not when
// crossing into a child element inside it.
if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) {
setDragActive(false);
}
}}
onDrop={(e) => {
e.preventDefault();
setDragActive(false);
ingest(e.dataTransfer?.files ?? null);
}}
>
<div className="relative flex-1 overflow-hidden">
{!hydrated && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div className="text-[11px] text-[var(--ink-faint)]">{t("editor.restoring")}</div>
</div>
)}
<textarea
value={hydrated ? content : ""}
onChange={(e) => {
if (!hydrated) return;
setContent(e.target.value);
setFilename(undefined);
const fmt = detectFormat(e.target.value);
if (fmt !== format) setFormat(fmt);
}}
placeholder={t("editor.placeholder")}
className="block h-full w-full resize-none border-0 bg-transparent p-5 font-[family-name:var(--font-mono)] text-[13px] leading-relaxed text-[var(--ink)] outline-none placeholder:text-[var(--ink-faint)]"
spellCheck={false}
disabled={!hydrated}
/>
{dragActive && (
<div
className="pointer-events-none absolute inset-2 z-20 flex flex-col items-center justify-center gap-2 rounded-2xl text-center"
style={{
background: "color-mix(in srgb, var(--paper) 92%, transparent)",
border: "2px dashed var(--coral)",
color: "var(--ink)",
}}
>
<div className="text-3xl">📂</div>
<div className="text-[13px] font-semibold">{t("editor.dropTitle")}</div>
<div className="text-[11px] text-[var(--ink-faint)]">{t("editor.dropHint")}</div>
</div>
)}
</div>
<AiPromptBar />
</div>
)}
{tab === "formats" && (
<FormatsGallery onLoaded={() => setTab("text")} />
)}
{tab === "samples" && (
<SamplesGallery onLoaded={() => setTab("text")} />
)}
</div>
</div>
);
}
function SaveIndicator({
status,
savedAt,
hasContent,
}: {
status: "idle" | "saving" | "saved" | "error";
savedAt: number | null;
hasContent: boolean;
}) {
const t = useT();
// re-render every ~15s so "saved Xs ago" stays current
const [, setTick] = useState(0);
useEffect(() => {
if (!hasContent) return;
const id = setInterval(() => setTick((n) => n + 1), 15_000);
return () => clearInterval(id);
}, [hasContent]);
if (!hasContent) return null;
if (status === "saving") {
return (
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[var(--coral)] shimmer">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--coral)]" />
{t("editor.saving")}
</span>
);
}
if (status === "error") {
return (
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[var(--red)]">
{t("editor.saveFailed")}
</span>
);
}
return (
<span
className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[var(--green)]"
title={t("editor.autosaveTooltip")}
>
<span className="w-1.5 h-1.5 rounded-full bg-[var(--green)]" />
{t("editor.autosaved")}
{savedAt && <span className="text-[var(--ink-faint)] tabular-nums">· {relativeTime(savedAt)}</span>}
</span>
);
}
+206
View File
@@ -0,0 +1,206 @@
"use client";
import { useMemo, useState, useRef, useEffect } from "react";
import { useStore, selectActiveTask } from "@/lib/store";
import { useT } from "@/lib/i18n";
import { copyToWechat } from "@/lib/export/wechat";
import { copyToZhihu } from "@/lib/export/zhihu";
import { copyHtml, copyText } from "@/lib/export/clipboard";
import {
copyIframeToClipboard,
downloadIframeAsImage,
} from "@/lib/export/image";
import { downloadHtml } from "@/lib/export/download";
import { extractHtml } from "@/lib/extract-html";
import { parseDeck } from "@/lib/deck";
import {
exportDeckPngZip,
exportDeckPptx,
exportDeckPrint,
} from "@/lib/export/deck";
import { parseHyperframes } from "@/lib/hyperframes";
import { exportRemotionZip } from "@/lib/export/remotion";
type ExportMenuProps = {
iframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
};
export function ExportMenu({ iframeRef }: ExportMenuProps) {
const html = useStore((s) => selectActiveTask(s)?.html ?? "");
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
const t = useT();
useEffect(() => {
const handler = (e: MouseEvent) => {
if (open && ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const showToast = (msg: string) => {
setToast(msg);
setTimeout(() => setToast(null), 2200);
};
const wrap =
(label: string, fn: () => Promise<void>) =>
async () => {
setBusy(true);
try {
await fn();
showToast(`${label}`);
} catch (e) {
showToast(`${e instanceof Error ? e.message : t("export.error.generic")}`);
} finally {
setBusy(false);
setOpen(false);
}
};
const cleanHtml = () => extractHtml(html);
// Re-parse for the deck section. Cheap because parseDeck is regex-only and
// the menu only opens on user click.
const deck = useMemo(() => parseDeck(extractHtml(html)), [html]);
const hyperframes = useMemo(() => parseHyperframes(extractHtml(html)), [html]);
const sections: Array<{
title: string;
actions: Array<{ id: string; label: string; emoji: string; fn: () => Promise<void> }>;
}> = [
{
title: t("export.section.platform"),
actions: [
{ id: "wechat", label: t("export.action.wechat"), emoji: "💬", fn: wrap(t("export.toast.wechat"), async () => { await copyToWechat(cleanHtml()); }) },
{ id: "zhihu", label: t("export.action.zhihu"), emoji: "🦓", fn: wrap(t("export.toast.zhihu"), async () => { await copyToZhihu(cleanHtml()); }) },
{ id: "twitter-img", label: t("export.action.twitterImg"), emoji: "🐦", fn: wrap(t("export.toast.image"), async () => {
if (!iframeRef.current) throw new Error(t("export.error.previewNotReady")); await copyIframeToClipboard(iframeRef.current);
}) },
],
},
{
title: t("export.section.raw"),
actions: [
{ id: "html", label: t("export.action.html"), emoji: "</>", fn: wrap(t("export.toast.html"), async () => { await copyHtml(cleanHtml()); }) },
{ id: "text", label: t("export.action.text"), emoji: "📝", fn: wrap(t("export.toast.text"), async () => {
const tmp = document.createElement("div"); tmp.innerHTML = cleanHtml(); await copyText(tmp.textContent ?? "");
}) },
],
},
{
title: t("export.section.download"),
actions: [
{ id: "download-html", label: t("export.action.downloadHtml"), emoji: "💾", fn: wrap(t("export.toast.htmlSaved"), async () => { downloadHtml(cleanHtml()); }) },
{ id: "download-png", label: t("export.action.downloadPng"), emoji: "🖼️", fn: wrap(t("export.toast.imgSaved"), async () => {
if (!iframeRef.current) throw new Error(t("export.error.previewNotReady")); await downloadIframeAsImage(iframeRef.current);
}) },
],
},
...(deck.isDeck
? [
{
title: t("export.section.deck", { n: deck.slides.length }),
actions: [
{
id: "deck-pdf",
label: t("export.action.deckPdf"),
emoji: "📄",
fn: wrap(t("export.toast.deckPdf"), async () => {
exportDeckPrint(deck.slides, deck.title);
}),
},
{
id: "deck-png-zip",
label: t("export.action.deckPngZip"),
emoji: "🗂️",
fn: wrap(t("export.toast.deckPngZip"), async () => {
await exportDeckPngZip(deck.slides, deck.title);
}),
},
{
id: "deck-pptx",
label: t("export.action.deckPptx"),
emoji: "🎬",
fn: wrap(t("export.toast.deckPptx"), async () => {
await exportDeckPptx(deck.slides, deck.title);
}),
},
],
},
]
: []),
...(hyperframes.isHyperframes
? [
{
title: t("export.section.hyperframes", { n: hyperframes.frames.length }),
actions: [
{
id: "hyperframes-remotion-zip",
label: t("export.action.remotionZip"),
emoji: "🎞️",
fn: wrap(t("export.toast.remotionZip"), async () => {
await exportRemotionZip(hyperframes, hyperframes.title, {
sourceHtml: cleanHtml(),
});
}),
},
],
},
]
: []),
];
const disabled = !html || busy;
return (
<div className="relative" ref={ref}>
<button onClick={() => setOpen((o) => !o)} disabled={disabled} className="btn-ink">
{t("export.button")}
</button>
{open && (
<div
data-testid="export-menu"
className="absolute right-0 z-30 mt-2 w-72 od-fade-in overflow-hidden rounded-2xl"
style={{
background: "var(--surface)",
border: "1px solid var(--line-soft)",
boxShadow: "0 30px 60px -20px rgba(21, 20, 15, 0.25)",
}}
>
{sections.map((sec, sIdx) => (
<div key={sec.title} style={sIdx ? { borderTop: "1px solid var(--line-faint)" } : undefined}>
<div className="px-4 pt-3 pb-1.5 text-[10px] uppercase tracking-[0.18em] text-[var(--ink-faint)]">
{sec.title}
</div>
<div className="px-1 pb-1">
{sec.actions.map((a) => (
<button
key={a.id}
onClick={a.fn}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left text-[13px] hover:bg-[var(--paper)]"
>
<span className="w-6 text-center">{a.emoji}</span>
<span className="text-[var(--ink-soft)]">{a.label}</span>
</button>
))}
</div>
</div>
))}
</div>
)}
{toast && (
<div
className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-full px-4 py-2 text-sm shadow-lg od-fade-in"
style={{ background: "var(--ink)", color: "var(--paper)" }}
>
{toast}
</div>
)}
</div>
);
}
+526
View File
@@ -0,0 +1,526 @@
"use client";
import { useMemo, useState } from "react";
import { useStore } from "@/lib/store";
import { useT } from "@/lib/i18n";
/**
* Format gallery — content-format snippets the user can one-click load into
* the editor. Different from `samples-gallery.tsx` (which ships fully
* pre-rendered HTML samples tied to specific skills): here every card is a
* tiny example of an *input* shape (.md / .pdf / .csv / .json / .sql / .yaml /
* image, …). After loading, the template picker in the top toolbar decides
* the *output* shape.
*/
type FormatKind = "text" | "data" | "code" | "image";
type FormatExample = {
id: string;
ext: string;
label: string;
icon: string;
/** which group chip surfaces it (and tints the card) */
kind: FormatKind;
/** short human description shown under the title */
description: string;
/** stored as task.format (matches `DetectedFormat`) */
format: string;
/** stored as task.filename */
filename: string;
/** content pasted into the textarea */
content: string;
};
// Tiny inline SVG → data URL helper. Kept inline so the gallery has no extra
// network deps; an SVG is fine for image-format demos because the file
// pipeline only cares that the upload becomes `![name](data:image/...)`.
function svgDataUrl(svg: string): string {
return `data:image/svg+xml;base64,${typeof window === "undefined" ? Buffer.from(svg).toString("base64") : btoa(unescape(encodeURIComponent(svg)))}`;
}
const PHOTO_PLACEHOLDER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="400" viewBox="0 0 640 400"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f4a259"/><stop offset="1" stop-color="#c96442"/></linearGradient></defs><rect width="640" height="400" fill="url(#g)"/><g fill="#fff" font-family="-apple-system, Helvetica, Arial" text-anchor="middle"><text x="320" y="190" font-size="40" font-weight="700">PNG sample</text><text x="320" y="234" font-size="18" opacity="0.85">640 × 400 · placeholder photo</text></g><circle cx="500" cy="100" r="40" fill="#fff" opacity="0.9"/></svg>`;
const CHART_PLACEHOLDER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="360" viewBox="0 0 600 360"><rect width="600" height="360" fill="#faf9f7"/><g fill="#15140f"><text x="36" y="48" font-size="20" font-weight="700" font-family="-apple-system, Helvetica, Arial">Sales by quarter</text></g><g fill="#c96442"><rect x="80" y="140" width="60" height="170"/><rect x="180" y="100" width="60" height="210"/><rect x="280" y="120" width="60" height="190"/><rect x="380" y="60" width="60" height="250"/><rect x="480" y="40" width="60" height="270"/></g><g fill="#8b8676" font-size="12" font-family="-apple-system, Helvetica, Arial" text-anchor="middle"><text x="110" y="330">Q1</text><text x="210" y="330">Q2</text><text x="310" y="330">Q3</text><text x="410" y="330">Q4 (proj)</text><text x="510" y="330">FY27</text></g></svg>`;
const EXAMPLES: FormatExample[] = [
{
id: "md-article",
ext: ".md",
label: "Markdown",
icon: "📝",
kind: "text",
description: "Headings, lists, tables, quotes — the default doc shape.",
format: "markdown",
filename: "quarterly-review.md",
content: `# Quarterly Review · Q3 2026
> A short note on what shipped this quarter and what's next.
## Highlights
- **Revenue** grew 23% QoQ
- 4 new enterprise logos closed
- Deploy time: 12 min → 4 min
## Numbers
| Metric | Q2 | Q3 | Δ |
| ------ | --- | ---- | ----- |
| MRR | $84k | $103k | +23% |
| Churn | 4.1% | 3.2% | -0.9pp |
| NPS | 41 | 48 | +7 |
## What's next
1. Hire two senior engineers
2. Ship the v2 API
3. Open a Tokyo office
\`\`\`bash
$ npm run deploy --target=tokyo
\`\`\`
[Read the full report →](https://example.com)
`,
},
{
id: "txt-note",
ext: ".txt",
label: "Plain text",
icon: "🗒️",
kind: "text",
description: "Raw narrative — paste a draft and let the template style it.",
format: "text",
filename: "memo.txt",
content: `Subject: A note on shipping cadence
Team,
The last two weeks taught us that small, frequent releases beat one big drop.
We cut a hotfix in 12 minutes on Tuesday and nobody noticed — that's the goal.
Three things going forward:
First, we ship behind flags. Always.
Second, no green deploy past 4 pm on Fridays. The on-call should sleep.
Third, every release notes file ends with one customer-visible sentence.
If anything blocks you on this, ping me directly.
— Sam
`,
},
{
id: "pdf-paper",
ext: ".pdf",
label: "PDF",
icon: "📄",
kind: "text",
description: "Text-layer PDFs — extracted locally into page sections.",
format: "pdf",
filename: "agentic-rl-paper.pdf",
content: `# PDF: agentic-rl-paper.pdf
Source: PDF
Pages: 3
Extraction: embedded text
## Page 1
Agentic reinforcement learning focuses on agents that can plan, act, observe feedback, and improve policy behavior across multi-step environments.
## Page 2
LLM reinforcement learning often optimizes model outputs using preference data, reward models, or task-specific feedback over generated responses.
## Page 3
The practical distinction is workflow scope: agentic RL evaluates behavior across trajectories, while LLM RL usually evaluates individual or batched language outputs.
`,
},
{
id: "csv-people",
ext: ".csv",
label: "CSV",
icon: "📊",
kind: "data",
description: "Comma-separated rows — agents render this as styled tables.",
format: "csv",
filename: "team.csv",
content: `name,role,city,joined,salary
Alice Chen,Engineer,Shanghai,2021-04-12,180000
Bob Park,Designer,Seoul,2020-11-03,150000
Carla Ruiz,PM,Madrid,2022-08-21,165000
Daniel Wu,Engineer,Taipei,2023-02-14,175000
Elena Costa,Researcher,Milan,2021-09-30,170000
Felix Oduya,Engineer,Nairobi,2024-01-08,160000
`,
},
{
id: "tsv-metrics",
ext: ".tsv",
label: "TSV",
icon: "📈",
kind: "data",
description: "Tab-separated — what you get pasting from Excel / Sheets.",
format: "tsv",
filename: "weekly-metrics.tsv",
content: `week\tsignups\tactivations\tretained_d7\trevenue_usd
2026-W14\t842\t611\t418\t12480
2026-W15\t903\t672\t461\t13720
2026-W16\t1124\t831\t579\t16950
2026-W17\t987\t714\t503\t14280
2026-W18\t1208\t912\t648\t18410
`,
},
{
id: "xlsx-multisheet",
ext: ".xlsx",
label: "Excel",
icon: "🧮",
kind: "data",
description: "Multi-sheet workbook — flattened to CSV blocks per sheet.",
format: "csv",
filename: "fy26-plan.xlsx",
content: `# Sheet: Sales
month,product,units,revenue_usd
2026-01,Pro plan,142,28400
2026-02,Pro plan,168,33600
2026-03,Pro plan,201,40200
2026-01,Team plan,38,19000
2026-02,Team plan,44,22000
2026-03,Team plan,51,25500
# Sheet: Pipeline
stage,deal,owner,arr_usd
qualified,Acme Co,Alice,48000
proposal,Globex,Daniel,72000
negotiation,Initech,Carla,96000
closed_won,Umbrella,Bob,120000
`,
},
{
id: "json-object",
ext: ".json",
label: "JSON",
icon: "🧬",
kind: "code",
description: "Structured object — good for product cards, profiles, specs.",
format: "json",
filename: "product.json",
content: `{
"product": "HTML Anything",
"version": "0.4.0",
"tagline": "Anything → beautiful HTML",
"author": {
"name": "pftom",
"url": "https://github.com/pftom"
},
"metrics": {
"weekly_active_users": 1240,
"conversions_total": 8123,
"avg_first_byte_ms": 1820
},
"features": [
"60+ templates",
"diff-edit mode",
"i18n (en / zh-CN)",
"local agents (Claude Code, Codex, Gemini)"
],
"shipped": true
}
`,
},
{
id: "sql-query",
ext: ".sql",
label: "SQL",
icon: "🛢️",
kind: "code",
description: "Queries and DDL — show the query + format the result.",
format: "sql",
filename: "top-customers.sql",
content: `-- Top 10 customers by lifetime value (FY26)
SELECT
c.id,
c.name,
c.country,
SUM(o.amount_usd) AS lifetime_value,
COUNT(o.id) AS order_count,
MIN(o.created_at) AS first_order,
MAX(o.created_at) AS last_order
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.created_at >= '2025-04-01'
AND o.status = 'paid'
GROUP BY c.id, c.name, c.country
HAVING SUM(o.amount_usd) > 10000
ORDER BY lifetime_value DESC
LIMIT 10;
`,
},
{
id: "yaml-config",
ext: ".yaml",
label: "YAML",
icon: "⚙️",
kind: "code",
description: "Config files — k8s, GitHub Actions, openapi specs.",
format: "yaml",
filename: "deploy.yaml",
content: `project: html-anything
version: 0.4.0
description: Anything → beautiful HTML
agents:
- id: claude-code
label: Claude Code
protocol: stdin
models: [opus-4-7, sonnet-4-6, haiku-4-5]
- id: codex
label: OpenAI Codex
protocol: argv
models: [gpt-5, gpt-4.1]
- id: gemini
label: Gemini CLI
protocol: stdin
models: [gemini-2.5-pro]
deploy:
region: us-east-1
replicas: 3
resources:
cpu: "500m"
memory: "1Gi"
env:
NODE_ENV: production
LOG_LEVEL: info
`,
},
{
id: "html-page",
ext: ".html",
label: "HTML",
icon: "🌐",
kind: "code",
description: "A page or fragment — the agent restyles it or extracts content.",
format: "html",
filename: "snippet.html",
content: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Hello, HTML Anything</title>
</head>
<body>
<header>
<h1>It works!</h1>
<p class="lede">Drop any HTML here and the agent will restyle it for the picked template.</p>
</header>
<section>
<h2>What you can do</h2>
<ul>
<li>Paste a Notion / Linear export</li>
<li>Drop in a scraped page</li>
<li>Hand-roll a quick prototype</li>
</ul>
</section>
</body>
</html>
`,
},
{
id: "png-photo",
ext: ".png",
label: "PNG",
icon: "🖼️",
kind: "image",
description: "Bitmap photo — embedded as data URL, agent writes copy around it.",
format: "image",
filename: "photo.png",
content: `![photo.png](${svgDataUrl(PHOTO_PLACEHOLDER_SVG)})
Caption: a placeholder hero image. Replace with a real photo, then ⌘+Enter to render in the picked template (Xiaohongshu card, Twitter card, magazine…).
`,
},
{
id: "jpg-chart",
ext: ".jpg",
label: "JPG",
icon: "📷",
kind: "image",
description: "Chart / screenshot — agent annotates it with a styled writeup.",
format: "image",
filename: "sales-chart.jpg",
content: `![sales-chart.jpg](${svgDataUrl(CHART_PLACEHOLDER_SVG)})
Sales by quarter — FY26 closed with Q4 at $250k (projected) and FY27 starting at $270k. The agent will turn this into a finance report card or a magazine pull-out, depending on the template.
`,
},
];
const KIND_LABEL: Record<FormatKind, { en: string; zh: string; tint: string }> = {
text: { en: "Text", zh: "文本", tint: "rgba(35,72,184,0.10)" },
data: { en: "Data", zh: "数据", tint: "rgba(31,122,58,0.10)" },
code: { en: "Code", zh: "代码", tint: "rgba(108,58,166,0.10)" },
image: { en: "Image", zh: "图片", tint: "rgba(201,100,66,0.12)" },
};
export function FormatsGallery({ onLoaded }: { onLoaded?: () => void }) {
const setContent = useStore((s) => s.setContent);
const setFormat = useStore((s) => s.setFormat);
const setFilename = useStore((s) => s.setFilename);
const pushLog = useStore((s) => s.pushLog);
const locale = useStore((s) => s.locale);
const t = useT();
const [filter, setFilter] = useState<FormatKind | "all">("all");
const filters: Array<{ id: FormatKind | "all"; label: string; emoji: string }> = useMemo(() => {
const zh = locale === "zh-CN";
return [
{ id: "all", label: t("samples.filter.all"), emoji: "✨" },
{ id: "text", label: zh ? KIND_LABEL.text.zh : KIND_LABEL.text.en, emoji: "📝" },
{ id: "data", label: zh ? KIND_LABEL.data.zh : KIND_LABEL.data.en, emoji: "📊" },
{ id: "code", label: zh ? KIND_LABEL.code.zh : KIND_LABEL.code.en, emoji: "🧬" },
{ id: "image", label: zh ? KIND_LABEL.image.zh : KIND_LABEL.image.en, emoji: "🖼️" },
];
}, [locale, t]);
const visible = useMemo(
() => (filter === "all" ? EXAMPLES : EXAMPLES.filter((e) => e.kind === filter)),
[filter],
);
const handleLoad = (ex: FormatExample) => {
setContent(ex.content);
setFormat(ex.format);
setFilename(ex.filename);
pushLog({
kind: "info",
text: t("formats.loadedLog", { name: ex.filename, fmt: ex.format }),
});
onLoaded?.();
};
return (
<div className="flex h-full flex-col">
<div
className="flex items-start justify-between gap-3 px-4 py-3"
style={{ borderBottom: "1px solid var(--line-faint)", background: "var(--paper)" }}
>
<div>
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-[var(--ink-faint)] mb-1.5">
{t("formats.eyebrow")}
</div>
<div className="text-[11.5px] text-[var(--ink-mute)] leading-snug max-w-md">
{t("formats.subtitle")}
</div>
</div>
<div className="text-[11px] text-[var(--ink-faint)] whitespace-nowrap mt-1">
{visible.length} / {EXAMPLES.length}
</div>
</div>
<div
className="flex gap-1.5 overflow-x-auto px-4 py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
style={{ borderBottom: "1px solid var(--line-faint)", background: "var(--paper)" }}
>
{filters.map((c) => {
const active = filter === c.id;
return (
<button
key={c.id}
onClick={() => setFilter(c.id)}
className="shrink-0 inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[12px] transition-colors"
style={
active
? { background: "var(--ink)", color: "var(--paper)" }
: {
background: "var(--surface)",
color: "var(--ink-mute)",
border: "1px solid var(--line-faint)",
}
}
>
<span>{c.emoji}</span>
<span>{c.label}</span>
</button>
);
})}
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{visible.map((ex) => (
<FormatCard key={ex.id} ex={ex} onLoad={() => handleLoad(ex)} />
))}
</div>
</div>
</div>
);
}
function FormatCard({ ex, onLoad }: { ex: FormatExample; onLoad: () => void }) {
const t = useT();
const tint = KIND_LABEL[ex.kind].tint;
const previewText = ex.content.split("\n").slice(0, 6).join("\n");
return (
<div
className="group relative flex flex-col overflow-hidden rounded-2xl transition-all hover:-translate-y-0.5"
style={{
background: "var(--surface)",
border: "1px solid var(--line-soft)",
boxShadow: "0 1px 0 var(--line-faint), 0 14px 32px -22px rgba(21,20,15,0.18)",
}}
>
<div
className="flex items-center justify-between gap-2 px-4 py-2.5"
style={{ background: tint, borderBottom: "1px solid var(--line-faint)" }}
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-[18px] leading-none">{ex.icon}</span>
<span className="font-semibold text-[13px] text-[var(--ink)] truncate">{ex.label}</span>
<span
className="rounded px-1.5 py-0.5 text-[10px] font-mono tracking-wide"
style={{ background: "var(--paper)", color: "var(--ink-soft)", border: "1px solid var(--line-faint)" }}
>
{ex.ext}
</span>
</div>
<span className="text-[10px] uppercase tracking-[0.14em] text-[var(--ink-faint)] whitespace-nowrap">
{ex.format}
</span>
</div>
<div className="flex flex-1 flex-col gap-2 p-4">
<div className="text-[11.5px] text-[var(--ink-mute)] leading-snug">{ex.description}</div>
<pre
className="rounded-md px-2.5 py-2 text-[10.5px] leading-snug font-[family-name:var(--font-mono)] overflow-hidden whitespace-pre"
style={{
background: "var(--paper)",
color: "var(--ink-soft)",
border: "1px solid var(--line-faint)",
maxHeight: "5.4em",
}}
title={ex.content}
>
{previewText}
</pre>
<div className="flex items-center justify-between gap-2 pt-1 mt-auto">
<span
className="min-w-0 truncate text-[10px] uppercase tracking-[0.14em] text-[var(--ink-faint)]"
title={ex.filename}
>
{ex.filename}
</span>
<button
onClick={onLoad}
className="shrink-0 whitespace-nowrap inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[12px] font-semibold transition-all"
style={{
background: "var(--coral)",
color: "#fff",
boxShadow: "0 8px 18px -12px rgba(201,100,66,0.85)",
}}
>
{t("formats.loadButton")}
</button>
</div>
</div>
</div>
);
}
+493
View File
@@ -0,0 +1,493 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { diffLines, type ChangeObject } from "diff";
import { selectActiveTask, useStore } from "@/lib/store";
import {
deleteRun,
listRuns,
MAX_VERSIONS_PER_TASK,
type RunRecord,
} from "@/lib/history/db";
import { isCurrentRun } from "@/lib/history/is-current";
import { previewHtml } from "@/lib/extract-html";
import { relativeTime, useMounted } from "@/lib/use-autosave";
import { useT } from "@/lib/i18n";
type Mode = "list" | "diff";
/**
* Per-task version timeline. Reads from IndexedDB so the full HTML payloads
* for past runs don't bloat localStorage. Lets the user inspect, restore,
* or side-by-side compare any of the last `MAX_VERSIONS_PER_TASK` versions.
*/
export function HistoryPane() {
const open = useStore((s) => s.historyPaneOpen);
const setOpen = useStore((s) => s.setHistoryPaneOpen);
const activeTaskId = useStore((s) => s.activeTaskId);
const activeTask = useStore(selectActiveTask);
const setHtmlFor = useStore((s) => s.setHtmlFor);
const setContent = useStore((s) => s.setContent);
const commitBase = useStore((s) => s.commitBaseFor);
const setStatusFor = useStore((s) => s.setStatusFor);
const t = useT();
const mounted = useMounted();
const [runs, setRuns] = useState<RunRecord[]>([]);
const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<Mode>("list");
// Versions selected for the diff view. Left = older, right = newer (by default).
const [leftId, setLeftId] = useState<string | null>(null);
const [rightId, setRightId] = useState<string | null>(null);
const [showSourceDiff, setShowSourceDiff] = useState(false);
// The persist middleware writes synchronously on every action, so we
// refresh the list whenever the active task's html changes — that's our
// signal that a new version was just committed.
const activeHtml = activeTask?.html ?? "";
const refresh = useCallback(() => {
if (!activeTaskId) {
setRuns([]);
return;
}
setLoading(true);
listRuns(activeTaskId)
.then((rows) => setRuns(rows))
.finally(() => setLoading(false));
}, [activeTaskId]);
useEffect(() => {
if (!open) return;
refresh();
}, [open, refresh, activeHtml]);
// Whenever the task changes, drop back to list mode so we don't render
// diff against a stale version pair.
useEffect(() => {
setMode("list");
setLeftId(null);
setRightId(null);
setShowSourceDiff(false);
}, [activeTaskId]);
if (!open) return null;
const onOpenDiff = (record: RunRecord) => {
// Compare the picked version against the latest run if there is one,
// otherwise against itself (degenerate but doesn't crash).
const latest = runs[0];
const pickRight = latest && latest.id !== record.id ? latest.id : record.id;
setLeftId(record.id);
setRightId(pickRight);
setMode("diff");
};
const onRestore = (record: RunRecord) => {
if (!activeTaskId) return;
if (!confirm(t("history.restoreConfirm", { v: record.version }))) return;
setHtmlFor(activeTaskId, record.html);
setContent(record.content);
setStatusFor(activeTaskId, "done");
// Snapshot the restored state as a new version so the user can roll
// forward again later; commitBaseFor also resets the diff-edit baseline.
commitBase(activeTaskId);
refresh();
};
const onDelete = async (record: RunRecord) => {
if (!activeTaskId) return;
if (!confirm(t("history.deleteConfirm", { v: record.version }))) return;
await deleteRun(activeTaskId, record.version);
refresh();
};
return (
<aside
className="flex flex-col"
style={{
width: 280,
background: "var(--paper)",
borderRight: "1px solid var(--line-faint)",
}}
>
<div
className="flex items-center justify-between gap-2 px-4 py-3"
style={{ borderBottom: "1px solid var(--line-faint)" }}
>
<div className="flex min-w-0 items-center gap-2">
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-[var(--ink-faint)]">
{t("history.heading")}
</span>
<span
className="rounded-full px-1.5 text-[10px] font-mono tabular-nums"
style={{ background: "var(--surface)", color: "var(--ink-mute)", border: "1px solid var(--line-faint)" }}
title={t("history.versionCap", { n: MAX_VERSIONS_PER_TASK })}
>
{runs.length}
</span>
</div>
<div className="flex items-center gap-1">
{mode === "diff" && (
<button
onClick={() => setMode("list")}
className="rounded px-1.5 py-0.5 text-[11px] text-[var(--ink-mute)] hover:text-[var(--ink)]"
title={t("history.backToList")}
>
{t("history.backToList")}
</button>
)}
<button
onClick={() => setOpen(false)}
className="text-[14px] text-[var(--ink-faint)] hover:text-[var(--ink)] transition-colors px-1"
title={t("history.close")}
aria-label={t("history.close")}
>
</button>
</div>
</div>
{!activeTaskId ? (
<div className="px-4 py-6 text-center text-[12px] text-[var(--ink-mute)]">
{t("history.empty.noTask")}
</div>
) : mode === "list" ? (
<HistoryList
runs={runs}
loading={loading}
mounted={mounted}
activeHtml={activeHtml}
onCompare={onOpenDiff}
onRestore={onRestore}
onDelete={onDelete}
/>
) : (
<DiffView
runs={runs}
leftId={leftId}
rightId={rightId}
onPickLeft={setLeftId}
onPickRight={setRightId}
showSource={showSourceDiff}
onToggleSource={() => setShowSourceDiff((v) => !v)}
/>
)}
</aside>
);
}
function HistoryList({
runs,
loading,
mounted,
activeHtml,
onCompare,
onRestore,
onDelete,
}: {
runs: RunRecord[];
loading: boolean;
mounted: boolean;
activeHtml: string;
onCompare: (r: RunRecord) => void;
onRestore: (r: RunRecord) => void;
onDelete: (r: RunRecord) => void;
}) {
const t = useT();
if (loading && runs.length === 0) {
return (
<div className="px-4 py-6 text-center text-[12px] text-[var(--ink-mute)]">
{t("history.loading")}
</div>
);
}
if (runs.length === 0) {
return (
<div className="px-4 py-6 text-center text-[12px] text-[var(--ink-mute)]">
{t("history.empty.noVersions")}
</div>
);
}
return (
<div className="flex-1 overflow-y-auto p-2 pt-2.5">
{runs.map((r) => (
<HistoryCard
key={r.id}
run={r}
isCurrent={isCurrentRun(r, runs, activeHtml)}
mounted={mounted}
onCompare={() => onCompare(r)}
onRestore={() => onRestore(r)}
onDelete={() => onDelete(r)}
/>
))}
</div>
);
}
function HistoryCard({
run,
isCurrent,
mounted,
onCompare,
onRestore,
onDelete,
}: {
run: RunRecord;
isCurrent: boolean;
mounted: boolean;
onCompare: () => void;
onRestore: () => void;
onDelete: () => void;
}) {
const t = useT();
const sizeKB = (run.html.length / 1024).toFixed(1);
return (
<div
className="group relative mb-1.5 rounded-xl px-3 py-2.5 transition-colors hover:bg-[var(--surface)]"
style={{
border: isCurrent ? "1px solid var(--line-soft)" : "1px solid transparent",
background: isCurrent ? "var(--surface)" : "transparent",
}}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span
className="font-mono text-[11px] tabular-nums"
style={{ color: isCurrent ? "var(--coral)" : "var(--ink-mute)" }}
>
v{run.version}
</span>
{isCurrent && (
<span
className="rounded px-1.5 py-0.5 text-[9px] uppercase tracking-wide"
style={{ background: "var(--coral-soft)", color: "var(--coral)" }}
>
{t("history.current")}
</span>
)}
</div>
<span className="font-mono text-[10px] tabular-nums text-[var(--ink-faint)]">
{sizeKB} KB
</span>
</div>
<div className="mt-0.5 text-[10.5px] text-[var(--ink-faint)]" suppressHydrationWarning>
{mounted ? relativeTime(run.ts) : ""}
</div>
<div className="mt-1.5 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={onCompare}
className="rounded border px-1.5 py-0.5 text-[10.5px] text-[var(--ink-mute)] hover:border-[var(--ink)]/30 hover:text-[var(--ink)]"
style={{ borderColor: "var(--line-faint)" }}
>
{t("history.compare")}
</button>
<button
onClick={onRestore}
disabled={isCurrent}
className="rounded border px-1.5 py-0.5 text-[10.5px] text-[var(--ink-mute)] hover:border-[var(--ink)]/30 hover:text-[var(--ink)] disabled:cursor-not-allowed disabled:opacity-40"
style={{ borderColor: "var(--line-faint)" }}
>
{t("history.restore")}
</button>
<button
onClick={onDelete}
className="ml-auto rounded px-1.5 py-0.5 text-[10.5px] text-[var(--ink-faint)] hover:text-[var(--red)]"
title={t("history.delete")}
>
</button>
</div>
</div>
);
}
function DiffView({
runs,
leftId,
rightId,
onPickLeft,
onPickRight,
showSource,
onToggleSource,
}: {
runs: RunRecord[];
leftId: string | null;
rightId: string | null;
onPickLeft: (id: string) => void;
onPickRight: (id: string) => void;
showSource: boolean;
onToggleSource: () => void;
}) {
const t = useT();
const left = useMemo(() => runs.find((r) => r.id === leftId) ?? null, [runs, leftId]);
const right = useMemo(() => runs.find((r) => r.id === rightId) ?? null, [runs, rightId]);
const changes = useMemo<ChangeObject<string>[]>(() => {
if (!showSource || !left || !right) return [];
// Line-level diff over the raw HTML source. This is line-level not
// visual; we explicitly punted on visual diff in the design.
return diffLines(left.html, right.html);
}, [showSource, left, right]);
return (
<div className="flex flex-1 min-h-0 flex-col">
<div
className="grid grid-cols-2 gap-1.5 px-3 py-2 text-[11px]"
style={{ borderBottom: "1px solid var(--line-faint)" }}
>
<VersionPicker
runs={runs}
value={leftId}
onChange={onPickLeft}
label={t("history.diff.leftLabel")}
/>
<VersionPicker
runs={runs}
value={rightId}
onChange={onPickRight}
label={t("history.diff.rightLabel")}
/>
</div>
<div
className="flex items-center justify-between gap-2 px-3 py-2"
style={{ borderBottom: "1px solid var(--line-faint)" }}
>
<label className="flex items-center gap-1.5 text-[11px] text-[var(--ink-mute)] cursor-pointer">
<input
type="checkbox"
checked={showSource}
onChange={onToggleSource}
/>
{t("history.diff.showSource")}
</label>
{left && right && (
<span className="font-mono text-[10px] text-[var(--ink-faint)] tabular-nums">
v{left.version} v{right.version}
</span>
)}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{!left || !right ? (
<div className="px-4 py-6 text-center text-[12px] text-[var(--ink-mute)]">
{t("history.diff.pickPair")}
</div>
) : showSource ? (
<SourceDiff changes={changes} />
) : (
<VisualDiff left={left} right={right} />
)}
</div>
</div>
);
}
function VersionPicker({
runs,
value,
onChange,
label,
}: {
runs: RunRecord[];
value: string | null;
onChange: (id: string) => void;
label: string;
}) {
return (
<label className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-[0.16em] text-[var(--ink-faint)]">
{label}
</span>
<select
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-md bg-[var(--surface)] px-1.5 py-1 text-[11px] text-[var(--ink)] outline-none"
style={{ border: "1px solid var(--line-faint)" }}
>
{runs.map((r) => (
<option key={r.id} value={r.id}>
v{r.version} · {(r.html.length / 1024).toFixed(1)} KB
</option>
))}
</select>
</label>
);
}
function VisualDiff({ left, right }: { left: RunRecord; right: RunRecord }) {
// Two iframes rendering each version's HTML stacked vertically (the pane
// is narrow at 280px so horizontal split would shrink each render below
// any useful resolution). srcdoc is intentional: it sandboxes scripts and
// keeps history previews from contaminating the real preview pane.
const t = useT();
return (
<div className="flex h-full flex-col">
<DiffIframe label={`v${left.version}`} html={left.html} caption={t("history.diff.before")} />
<div className="h-px shrink-0" style={{ background: "var(--line-faint)" }} />
<DiffIframe label={`v${right.version}`} html={right.html} caption={t("history.diff.after")} />
</div>
);
}
function DiffIframe({ html, label, caption }: { html: string; label: string; caption: string }) {
const srcDoc = useMemo(() => previewHtml(html), [html]);
return (
<div className="relative flex-1 min-h-0">
<div
className="absolute left-1.5 top-1.5 z-10 rounded px-1.5 py-0.5 text-[9px] font-mono"
style={{ background: "rgba(0,0,0,0.6)", color: "#fff" }}
title={caption}
>
{label}
</div>
<iframe
srcDoc={srcDoc}
sandbox="allow-same-origin"
className="h-full w-full"
style={{ background: "#fff", border: 0 }}
title={caption}
/>
</div>
);
}
function SourceDiff({ changes }: { changes: ChangeObject<string>[] }) {
// Line diff rendered as plain monospaced text with added / removed lines
// tinted green / red. We deliberately don't escape — DOMPurify isn't
// needed because we render text content into a <pre>, not innerHTML.
return (
<div className="h-full overflow-auto bg-[var(--paper)] font-mono text-[10.5px] leading-snug">
{changes.map((c, i) => {
const lines = c.value.split("\n");
// diffLines often leaves an empty trailing element after the last \n —
// drop it so we don't render a blank ghost row per chunk.
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
const color = c.added
? "var(--green, #2f7d3a)"
: c.removed
? "var(--red, #c0392b)"
: "var(--ink-mute)";
const bg = c.added
? "rgba(47, 125, 58, 0.08)"
: c.removed
? "rgba(192, 57, 43, 0.08)"
: "transparent";
const prefix = c.added ? "+ " : c.removed ? "- " : " ";
return (
<div key={i} style={{ background: bg }}>
{lines.map((line, j) => (
<div
key={j}
className="whitespace-pre px-2"
style={{ color }}
>
{prefix}
{line}
</div>
))}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,75 @@
"use client";
import { useStore, type LayoutMode } from "@/lib/store";
import { useT, type DictKey } from "@/lib/i18n";
const MODES: Array<{ id: LayoutMode; tipKey: DictKey; labelKey: DictKey; icon: React.ReactNode }> = [
{
id: "editor",
tipKey: "layout.tip.editor",
labelKey: "layout.label.editor",
icon: (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="2.5" width="13" height="11" rx="1.5" stroke="currentColor" strokeWidth="1.2" />
<rect x="1.5" y="2.5" width="9" height="11" rx="1.5" fill="currentColor" opacity="0.85" />
</svg>
),
},
{
id: "split",
tipKey: "layout.tip.split",
labelKey: "layout.label.split",
icon: (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="2.5" width="13" height="11" rx="1.5" stroke="currentColor" strokeWidth="1.2" />
<line x1="8" y1="2.5" x2="8" y2="13.5" stroke="currentColor" strokeWidth="1.2" />
</svg>
),
},
{
id: "preview",
tipKey: "layout.tip.preview",
labelKey: "layout.label.preview",
icon: (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="2.5" width="13" height="11" rx="1.5" stroke="currentColor" strokeWidth="1.2" />
<rect x="5.5" y="2.5" width="9" height="11" rx="1.5" fill="currentColor" opacity="0.85" />
</svg>
),
},
];
export function LayoutModeToggle() {
const mode = useStore((s) => s.layoutMode);
const setMode = useStore((s) => s.setLayoutMode);
const t = useT();
return (
<div
className="flex items-center gap-0.5 rounded-full p-0.5"
style={{ background: "var(--surface)", border: "1px solid var(--line)" }}
role="radiogroup"
aria-label={t("layout.aria.group")}
>
{MODES.map((m) => {
const active = mode === m.id;
return (
<button
key={m.id}
role="radio"
aria-checked={active}
onClick={() => setMode(m.id)}
title={t(m.tipKey)}
aria-label={t(m.labelKey)}
className="grid h-7 w-7 place-items-center rounded-full transition-colors"
style={{
background: active ? "var(--ink)" : "transparent",
color: active ? "var(--paper)" : "var(--ink-mute)",
}}
>
{m.icon}
</button>
);
})}
</div>
);
}
+588
View File
@@ -0,0 +1,588 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStore, selectActiveTask, type LogEntry, type RunStats } from "@/lib/store";
import { useT, type DictKey } from "@/lib/i18n";
import { previewHtml, extractHtml } from "@/lib/extract-html";
import { isDeck } from "@/lib/deck";
import { DeckViewer } from "./deck-viewer";
type PreviewTab = "preview" | "deck" | "code" | "log";
const PREVIEW_TAB_KEY: Record<PreviewTab, DictKey> = {
preview: "preview.tab.preview",
deck: "preview.tab.deck",
code: "preview.tab.code",
log: "preview.tab.log",
};
const STATUS_KEY: Record<string, DictKey> = {
idle: "preview.status.idle",
running: "preview.status.running",
done: "preview.status.done",
error: "preview.status.error",
};
const CHIP_KEYS: DictKey[] = [
"preview.placeholder.chip.article",
"preview.placeholder.chip.deck",
"preview.placeholder.chip.resume",
"preview.placeholder.chip.poster",
"preview.placeholder.chip.xiaohongshu",
"preview.placeholder.chip.twitterCard",
"preview.placeholder.chip.webProto",
"preview.placeholder.chip.dataReport",
];
// stable references so the selector doesn't force re-render when active task is missing
const EMPTY_LOG: LogEntry[] = [];
const EMPTY_STATS: RunStats = { outputBytes: 0, deltaCount: 0 };
export function PreviewPane({
iframeRef,
}: {
iframeRef?: React.MutableRefObject<HTMLIFrameElement | null>;
}) {
const html = useStore((s) => selectActiveTask(s)?.html ?? "");
const status = useStore((s) => selectActiveTask(s)?.status ?? "idle");
const log = useStore((s) => selectActiveTask(s)?.log ?? EMPTY_LOG);
const stats = useStore((s) => selectActiveTask(s)?.stats ?? EMPTY_STATS);
const activeTaskId = useStore((s) => s.activeTaskId);
const templateId = useStore((s) => selectActiveTask(s)?.templateId);
const setHtmlFor = useStore((s) => s.setHtmlFor);
// Template example HTML — fetched lazily when no task.html exists yet, so
// switching templates in the picker shows that template's pre-shipped
// `example.html` immediately, without burning agent tokens. Cleared as soon
// as Convert produces real html, never written to task state.
const [templateExample, setTemplateExample] = useState<string>("");
useEffect(() => {
// only fetch when there's no real output yet AND the run is idle
if (html || status === "running") {
setTemplateExample("");
return;
}
if (!templateId) return;
let cancelled = false;
fetch(`/api/templates/${encodeURIComponent(templateId)}/example`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (cancelled) return;
const exampleHtml = (data?.html ?? "") as string;
setTemplateExample(exampleHtml);
})
.catch(() => {
if (!cancelled) setTemplateExample("");
});
return () => {
cancelled = true;
};
}, [templateId, html, status]);
const [tab, setTab] = useState<PreviewTab>("preview");
const localRef = useRef<HTMLIFrameElement | null>(null);
const codeRef = useRef<HTMLTextAreaElement | null>(null);
const logRef = useRef<HTMLDivElement | null>(null);
const wrapRef = useRef<HTMLDivElement | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
// Bumping this remounts the iframe, forcing a clean re-render with the current
// HTML — useful when the streaming preview committed a partial render and the
// final state didn't repaint, or when injected scripts/styles need to re-init.
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((n) => n + 1), []);
const t = useT();
// Browser-level fullscreen for the whole preview pane. The Deck tab has its
// own fullscreen wired in DeckViewer; we only handle Preview / Source / Log.
useEffect(() => {
const onFs = () => setIsFullscreen(document.fullscreenElement === wrapRef.current);
document.addEventListener("fullscreenchange", onFs);
return () => document.removeEventListener("fullscreenchange", onFs);
}, []);
const toggleFullscreen = useCallback(() => {
const el = wrapRef.current;
if (!el) return;
if (document.fullscreenElement === el) {
document.exitFullscreen?.();
} else {
el.requestFullscreen?.().catch(() => {});
}
}, []);
// F to toggle fullscreen (only when not on Deck tab — DeckViewer handles its own).
useEffect(() => {
if (tab === "deck") return;
const onKey = (e: KeyboardEvent) => {
if (e.target instanceof HTMLElement) {
const tagName = e.target.tagName;
if (tagName === "INPUT" || tagName === "TEXTAREA" || e.target.isContentEditable) return;
}
if (e.key === "f" || e.key === "F") {
e.preventDefault();
toggleFullscreen();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [tab, toggleFullscreen]);
// Effective html for this render = real task output if any, else the
// template example fetched above. Downstream (deck detection, debounce,
// iframe srcDoc) treats both identically — the only difference is provenance.
const effectiveHtml = html || templateExample;
const isPreviewingTemplate = !html && !!templateExample;
// Detect deck off the cleaned (un-fenced) html — extract once for reuse.
const cleaned = useMemo(() => extractHtml(effectiveHtml), [effectiveHtml]);
const deckMode = useMemo(() => isDeck(cleaned), [cleaned]);
// First time we see a deck, auto-promote the user to the Deck tab so the
// feature is discoverable. We only do this once per task run (track the
// latest html length seen as the "trigger") to avoid stealing focus if the
// user explicitly switched back to the single-page preview.
const autoSwitchedRef = useRef(false);
useEffect(() => {
if (status === "running") return;
if (deckMode && !autoSwitchedRef.current && tab === "preview") {
setTab("deck");
autoSwitchedRef.current = true;
}
if (!deckMode) autoSwitchedRef.current = false;
if (!deckMode && tab === "deck") setTab("preview");
}, [deckMode, status, tab]);
// Debounce srcDoc updates to ~3 fps during streaming so the iframe
// doesn't reload on every delta. Last value always commits when status changes.
const [debouncedHtml, setDebouncedHtml] = useState(effectiveHtml);
useEffect(() => {
if (status !== "running") {
setDebouncedHtml(effectiveHtml);
return;
}
const id = setTimeout(() => setDebouncedHtml(effectiveHtml), 320);
return () => clearTimeout(id);
}, [effectiveHtml, status]);
const display = useMemo(() => previewHtml(debouncedHtml), [debouncedHtml]);
useEffect(() => {
if (iframeRef) iframeRef.current = localRef.current;
});
// Auto-scroll code to bottom while streaming. Skip while the user is editing
// (textarea is focused) so we don't yank the caret around.
useEffect(() => {
if (status !== "running" || !codeRef.current) return;
if (document.activeElement === codeRef.current) return;
codeRef.current.scrollTop = codeRef.current.scrollHeight;
}, [html, status]);
// Auto-scroll log to bottom
useEffect(() => {
if (logRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight;
}
}, [log]);
// Auto-switch to code tab when stream starts so user sees output forming
const prevStatusRef = useRef(status);
useEffect(() => {
if (prevStatusRef.current !== "running" && status === "running" && tab === "preview" && !html) {
// stay on preview but no-op; we keep preview as default for live HTML render
}
prevStatusRef.current = status;
}, [status, tab, html]);
const showMetrics = status !== "idle" || !!stats.startedAt;
// Present button is meaningful for Preview / Source / Log. The Deck tab has
// its own Present button inside DeckViewer; suppress ours there to avoid
// two competing fullscreens. Template-only previews count as "has html" for
// the Present button so users can preview at full size without converting.
const canPresent = tab !== "deck" && (tab !== "preview" || !!effectiveHtml);
return (
<div
ref={wrapRef}
className="flex h-full flex-col"
style={{ background: isFullscreen ? "#0a0a0a" : "var(--paper)" }}
>
{!isFullscreen && (
<div
className="flex items-center justify-between gap-2 px-4 py-2.5 text-sm"
style={{ borderBottom: "1px solid var(--line-faint)", background: "var(--surface)" }}
>
<div className="flex gap-1">
{(["preview", ...(deckMode ? (["deck"] as const) : []), "code", "log"] as const).map((id) => (
<button
key={id}
onClick={() => setTab(id)}
className={tab === id ? "pill pill-active" : "pill"}
style={tab === id ? undefined : { background: "transparent", border: "1px solid transparent" }}
>
{t(PREVIEW_TAB_KEY[id])}
{id === "log" && log.length > 0 && (
<span
className="ml-1 rounded-full px-1.5 py-0.5 text-[9px] font-bold"
style={{
background: tab === "log" ? "rgba(255,255,255,0.18)" : "var(--coral-soft)",
color: tab === "log" ? "#fff" : "var(--coral)",
}}
>
{log.length}
</span>
)}
</button>
))}
</div>
<div className="flex items-center gap-1.5">
{tab === "preview" && html && (
<button
onClick={refresh}
className="grid h-[22px] w-[22px] place-items-center rounded-full"
style={{
background: "transparent",
color: "var(--ink-soft)",
border: "1px solid var(--line)",
}}
title={t("preview.refreshTooltip")}
aria-label={t("preview.refresh")}
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12a9 9 0 1 1-3.46-7.1" />
<polyline points="21 3 21 9 15 9" />
</svg>
</button>
)}
{canPresent && (
<button
onClick={toggleFullscreen}
className="rounded-full px-2.5 py-0.5 text-[11px] font-medium"
style={{
background: "transparent",
color: "var(--ink-soft)",
border: "1px solid var(--line)",
}}
title={t("preview.presentTooltip")}
>
{t("preview.present")} <span className="opacity-50">F</span>
</button>
)}
<StatusPill status={status} />
</div>
</div>
)}
{!isFullscreen && showMetrics && <MetricsBar stats={stats} status={status} html={html} />}
<div className="relative flex-1 overflow-hidden">
{tab === "preview" && (
<>
{!effectiveHtml && <PreviewPlaceholder status={status} />}
{effectiveHtml && (
<>
<iframe
key={refreshKey}
ref={localRef}
title="preview"
srcDoc={display}
sandbox="allow-scripts allow-same-origin"
className="h-full w-full"
style={{ background: "#fff" }}
/>
{isPreviewingTemplate && (
<div
className="pointer-events-none absolute left-3 top-3 rounded-full px-2.5 py-1 text-[10.5px] font-medium tracking-wide"
style={{
background: "rgba(15, 14, 12, 0.78)",
color: "#f3efe6",
backdropFilter: "blur(6px)",
letterSpacing: "0.08em",
}}
>
· TEMPLATE EXAMPLE
</div>
)}
</>
)}
</>
)}
{tab === "deck" && (
<DeckViewer
html={cleaned}
active={tab === "deck"}
// Hand the active slide iframe to the parent ref so the existing
// ExportMenu's "PNG" / "Image" actions snapshot the *current
// slide*, not the underlying multi-slide doc.
onMainIframe={(el) => {
if (iframeRef) iframeRef.current = el;
localRef.current = el;
}}
/>
)}
{tab === "code" && (
<CodeEditor
html={html}
status={status}
taskId={activeTaskId}
setHtmlFor={setHtmlFor}
textareaRef={codeRef}
/>
)}
{tab === "log" && (
<LogPanel logRef={logRef} log={log} />
)}
{/* Fullscreen exit chip — only shown when this pane owns the fullscreen.
Deck mode is excluded because DeckViewer manages its own fullscreen
chrome (Present / Exit / nav arrows). */}
{isFullscreen && tab !== "deck" && (
<button
onClick={toggleFullscreen}
className="absolute right-3 top-3 rounded-full px-3 py-1.5 text-[11px] font-medium"
style={{
background: "rgba(255,255,255,0.10)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.18)",
backdropFilter: "blur(8px)",
}}
title={t("preview.presentTooltip")}
>
{t("preview.exitPresent")} <span className="opacity-60">F</span>
</button>
)}
</div>
</div>
);
}
function StatusPill({ status }: { status: string }) {
const t = useT();
const map: Record<string, { bg: string; fg: string; dot: boolean }> = {
idle: { bg: "transparent", fg: "var(--ink-faint)", dot: false },
running: { bg: "var(--coral-soft)", fg: "var(--coral)", dot: true },
done: { bg: "rgba(31,122,58,0.12)", fg: "var(--green)", dot: false },
error: { bg: "rgba(156,42,37,0.12)", fg: "var(--red)", dot: false },
};
const c = map[status] ?? map.idle;
return (
<span
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-medium"
style={{ background: c.bg, color: c.fg }}
>
{c.dot && <span className="pulse-dot" />}
{t(STATUS_KEY[status] ?? "preview.status.idle")}
</span>
);
}
function MetricsBar({ stats, status, html }: { stats: RunStats; status: string; html: string }) {
const t = useT();
// tick to keep elapsed/ttfb live while running
const [, setTick] = useState(0);
useEffect(() => {
if (status !== "running") return;
const id = setInterval(() => setTick((n) => n + 1), 250);
return () => clearInterval(id);
}, [status]);
const elapsed = stats.startedAt ? ((stats.endedAt ?? Date.now()) - stats.startedAt) / 1000 : 0;
const ttfb = stats.firstByteAt && stats.startedAt ? (stats.firstByteAt - stats.startedAt) / 1000 : null;
const sizeKB = html.length / 1024;
const live = status === "running";
const metrics: Array<{ label: string; value: string; hint?: string; live?: boolean }> = [
{ label: "Elapsed", value: `${elapsed.toFixed(1)}s`, live },
{ label: "TTFB", value: ttfb !== null ? `${ttfb.toFixed(2)}s` : "—", hint: t("preview.metric.ttfbHint") },
{ label: "Size", value: `${sizeKB.toFixed(1)} KB` },
...(stats.deltaCount > 0 ? [{ label: "Chunks", value: stats.deltaCount.toLocaleString() }] : []),
...(stats.outputTokens ? [{ label: "Output", value: `${stats.outputTokens.toLocaleString()} tok` }] : []),
...(stats.inputTokens ? [{ label: "Input", value: `${stats.inputTokens.toLocaleString()} tok` }] : []),
...(stats.cacheReadTokens ? [{ label: "Cache", value: `${stats.cacheReadTokens.toLocaleString()} tok` }] : []),
...(stats.costUsd !== undefined && stats.costUsd > 0
? [{ label: "Cost", value: `$${stats.costUsd.toFixed(4)}` }]
: []),
...(stats.model ? [{ label: "Model", value: stats.model.replace(/\[.*\]$/, "") }] : []),
];
return (
<div
className="flex flex-wrap items-center gap-x-5 gap-y-1.5 px-4 py-2"
style={{ borderBottom: "1px solid var(--line-faint)", background: "var(--paper)" }}
>
{metrics.map((m) => (
<Metric key={m.label} label={m.label} value={m.value} hint={m.hint} live={m.live} />
))}
</div>
);
}
function Metric({ label, value, hint, live }: { label: string; value: string; hint?: string; live?: boolean }) {
return (
<div className="flex items-baseline gap-1.5 whitespace-nowrap" title={hint}>
<span className="text-[9.5px] uppercase tracking-[0.14em] text-[var(--ink-faint)]">{label}</span>
<span
className="text-[12px] tabular-nums font-[family-name:var(--font-mono)]"
style={{ color: live ? "var(--coral)" : "var(--ink-soft)", fontWeight: live ? 500 : 400 }}
>
{value}
</span>
</div>
);
}
function CodeEditor({
html,
status,
taskId,
setHtmlFor,
textareaRef,
}: {
html: string;
status: string;
taskId: string;
setHtmlFor: (taskId: string, html: string) => void;
textareaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
}) {
const t = useT();
const isRunning = status === "running";
const editable = !isRunning;
const placeholder = isRunning ? t("preview.code.waiting") : t("preview.code.empty");
return (
<div className="flex h-full flex-col" style={{ background: "#15140f" }}>
<div
className="flex items-center justify-between gap-3 px-4 py-1.5 text-[10.5px]"
style={{
color: editable ? "#9ca28a" : "#8b8676",
borderBottom: "1px solid rgba(255,255,255,0.06)",
background: "rgba(255,255,255,0.02)",
}}
>
<span>{editable ? t("preview.code.editHint") : t("preview.code.lockedHint")}</span>
</div>
<textarea
ref={textareaRef}
value={html}
onChange={(e) => setHtmlFor(taskId, e.target.value)}
readOnly={!editable}
spellCheck={false}
placeholder={placeholder}
className="flex-1 w-full resize-none overflow-auto p-4 text-[11.5px] leading-relaxed font-[family-name:var(--font-mono)] focus:outline-none"
style={{
background: "#15140f",
color: "#e8e4dc",
caretColor: "#ffb55a",
border: "none",
tabSize: 2,
}}
/>
</div>
);
}
function LogPanel({
log,
logRef,
}: {
log: LogEntry[];
logRef: React.MutableRefObject<HTMLDivElement | null>;
}) {
const t = useT();
return (
<div ref={logRef} className="h-full overflow-auto p-3 text-[11.5px] font-[family-name:var(--font-mono)]" style={{ background: "var(--paper)" }}>
{log.length === 0 && (
<div className="text-[var(--ink-faint)] p-4 text-center">{t("preview.log.empty")}</div>
)}
{log.map((l, i) => (
<LogLine key={i} entry={l} />
))}
</div>
);
}
function LogLine({ entry }: { entry: LogEntry }) {
const colorMap: Record<string, { tag: string; bg: string; fg: string; text: string }> = {
info: { tag: "INFO", bg: "rgba(35,72,184,0.10)", fg: "var(--blue)", text: "var(--ink)" },
start: { tag: "START", bg: "var(--coral-soft)", fg: "var(--coral)", text: "var(--ink)" },
delta: { tag: "DELTA", bg: "rgba(31,122,58,0.10)", fg: "var(--green)", text: "var(--ink)" },
meta: { tag: "META", bg: "rgba(108,58,166,0.10)", fg: "var(--purple)", text: "var(--ink-soft)" },
stderr: { tag: "STDERR", bg: "rgba(178,98,0,0.10)", fg: "var(--amber)", text: "var(--ink-soft)" },
raw: { tag: "RAW", bg: "rgba(21,20,15,0.05)", fg: "var(--ink-faint)", text: "var(--ink-mute)" },
done: { tag: "DONE", bg: "rgba(31,122,58,0.10)", fg: "var(--green)", text: "var(--ink)" },
error: { tag: "ERROR", bg: "rgba(156,42,37,0.10)", fg: "var(--red)", text: "var(--red)" },
};
const c = colorMap[entry.kind] ?? colorMap.info;
const elapsed = entry.elapsed !== undefined ? `+${(entry.elapsed / 1000).toFixed(2)}s` : "";
return (
<div className="flex items-start gap-2 py-0.5 px-1 hover:bg-[var(--line-faint)] rounded">
<span className="text-[var(--ink-faint)] tabular-nums shrink-0 w-[58px]">{elapsed}</span>
<span
className="shrink-0 rounded px-1.5 py-px text-[10px] font-bold tracking-wider"
style={{ background: c.bg, color: c.fg }}
>
{c.tag}
</span>
<span style={{ color: c.text }} className="break-all whitespace-pre-wrap">
{entry.text}
</span>
</div>
);
}
function PreviewPlaceholder({ status }: { status: string }) {
const t = useT();
const isRunning = status === "running";
const [, setTick] = useState(0);
const stats = useStore((s) => selectActiveTask(s)?.stats ?? EMPTY_STATS);
useEffect(() => {
if (!isRunning) return;
const id = setInterval(() => setTick((n) => n + 1), 500);
return () => clearInterval(id);
}, [isRunning]);
const secWaited = stats.startedAt ? ((Date.now() - stats.startedAt) / 1000).toFixed(1) : "0";
return (
<div className="flex h-full flex-col items-center justify-center gap-4 text-center p-8" style={{ background: "var(--paper)" }}>
<div className={`text-6xl ${isRunning ? "shimmer" : ""}`}>{isRunning ? "✨" : "🪄"}</div>
<div>
<h2 className="text-[22px] font-semibold tracking-tight text-[var(--ink)] font-[family-name:var(--font-display)]">
{isRunning ? (
<>
{t("preview.placeholder.runningTitle.part1")}{" "}
<em className="serif-em">{t("preview.placeholder.runningTitle.accent")}</em>
</>
) : (
<>
{t("preview.placeholder.idleTitle.part1")}{" "}
<em className="serif-em">{t("preview.placeholder.idleTitle.accent")}</em>{" "}
{t("preview.placeholder.idleTitle.part2")}
</>
)}
</h2>
<p className="mt-2 text-[13px] text-[var(--ink-mute)] max-w-sm leading-relaxed">
{isRunning
? t("preview.placeholder.runningDescr", { sec: secWaited })
: t("preview.placeholder.idleDescr")}
</p>
</div>
{!isRunning && (
<div className="flex flex-wrap items-center justify-center gap-2 max-w-md">
{CHIP_KEYS.map((key) => (
<span key={key} className="pill" style={{ fontSize: 11 }}>
{t(key)}
</span>
))}
</div>
)}
</div>
);
}
+306
View File
@@ -0,0 +1,306 @@
"use client";
import { useMemo, useState } from "react";
import { useStore } from "@/lib/store";
import { useT } from "@/lib/i18n";
import {
useTemplates,
fetchTemplateExample,
scenarioLabelKey,
type TemplateDef,
} from "@/lib/templates";
/**
* Samples gallery — rendered on the "示例" tab in the editor pane. The list
* comes from `/api/templates` filtered to skills that ship a pre-rendered
* `example.html` (so every card has something to preview). Each card uses
* `<iframe src=…>` pointing at `/api/templates/<id>/preview` — the browser
* does the fetching + caching, no `srcDoc` round-trip through React state.
*/
export function SamplesGallery({ onLoaded }: { onLoaded?: () => void }) {
const templates = useTemplates();
const loadSample = useStore((s) => s.loadSample);
const tasks = useStore((s) => s.tasks);
const locale = useStore((s) => s.locale);
const t = useT();
const [filter, setFilter] = useState<string>("all");
const [loadingId, setLoadingId] = useState<string | null>(null);
const previewable = useMemo(
() => (templates ?? []).filter((tpl) => tpl.example?.hasHtml),
[templates],
);
// group filter chips by scenario (only show scenarios that have ≥1 previewable template)
const filterChips = useMemo(() => {
const seen = new Map<string, TemplateDef>();
for (const tpl of previewable) if (!seen.has(tpl.scenario)) seen.set(tpl.scenario, tpl);
return [
{ id: "all", label: t("samples.filter.all"), emoji: "✨" },
...Array.from(seen.entries()).map(([s, tpl]) => {
const k = scenarioLabelKey(s);
return { id: s, label: k ? t(k) : s, emoji: tpl.emoji };
}),
];
}, [previewable, t]);
const visible = useMemo(
() => (filter === "all" ? previewable : previewable.filter((tpl) => tpl.scenario === filter)),
[filter, previewable],
);
const loadedSampleIds = useMemo(
() => new Set(tasks.map((task) => task.sampleId).filter(Boolean) as string[]),
[tasks],
);
const handleLoad = async (tpl: TemplateDef) => {
setLoadingId(tpl.id);
try {
const ex = await fetchTemplateExample(tpl.id);
if (!ex) return;
loadSample({
id: ex.id,
name: ex.name,
content: ex.content,
format: ex.format,
templateId: ex.templateId,
html: ex.html,
});
onLoaded?.();
} finally {
setLoadingId(null);
}
};
return (
<div className="flex h-full flex-col">
{/* sticky filter strip */}
<div
className="flex items-start justify-between gap-3 px-4 py-3"
style={{ borderBottom: "1px solid var(--line-faint)", background: "var(--paper)" }}
>
<div>
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-[var(--ink-faint)] mb-1.5">
{t("samples.eyebrow")}
</div>
<div className="text-[11.5px] text-[var(--ink-mute)] leading-snug max-w-md">
{(() => {
const SEP = "\u0007";
const body = t("samples.subtitle.body", {
preRendered: SEP + "PRE" + SEP,
diff: SEP + "DIFF" + SEP,
});
return body.split(SEP).map((piece, i) => {
if (piece === "PRE") {
return (
<strong key={i} className="text-[var(--ink)]">
{t("samples.subtitle.preRendered")}
</strong>
);
}
if (piece === "DIFF") {
return (
<strong key={i} className="text-[var(--coral)]">
{t("samples.subtitle.diff")}
</strong>
);
}
const kbdParts = piece.split("⌘+Enter");
if (kbdParts.length === 2) {
return (
<span key={i}>
{kbdParts[0]}
<kbd className="px-1 py-px rounded bg-white border border-[var(--line)] font-mono text-[10px]">
+Enter
</kbd>
{kbdParts[1]}
</span>
);
}
return <span key={i}>{piece}</span>;
});
})()}
</div>
</div>
<div className="text-[11px] text-[var(--ink-faint)] whitespace-nowrap mt-1">
{visible.length} / {previewable.length}
</div>
</div>
<div
className="flex gap-1.5 overflow-x-auto px-4 py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
style={{ borderBottom: "1px solid var(--line-faint)", background: "var(--paper)" }}
>
{filterChips.map((c) => {
const active = filter === c.id;
return (
<button
key={c.id}
onClick={() => setFilter(c.id)}
className="shrink-0 inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[12px] transition-colors"
style={
active
? { background: "var(--ink)", color: "var(--paper)" }
: {
background: "var(--surface)",
color: "var(--ink-mute)",
border: "1px solid var(--line-faint)",
}
}
>
<span>{c.emoji}</span>
<span>{c.label}</span>
</button>
);
})}
</div>
<div className="flex-1 overflow-y-auto p-4">
{templates === undefined ? (
<div className="py-12 text-center text-[12px] text-[var(--ink-faint)]">
{t("samples.loading")}
</div>
) : (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{visible.map((tpl) => (
<SampleCard
key={tpl.id}
tpl={tpl}
loaded={!!tpl.example && loadedSampleIds.has(tpl.example.id)}
loading={loadingId === tpl.id}
onLoad={() => handleLoad(tpl)}
/>
))}
</div>
)}
</div>
</div>
);
}
function SampleCard({
tpl,
loaded,
loading,
onLoad,
}: {
tpl: TemplateDef;
loaded: boolean;
loading: boolean;
onLoad: () => void;
}) {
const t = useT();
const locale = useStore((s) => s.locale);
const example = tpl.example;
const previewUrl = `/api/templates/${encodeURIComponent(tpl.id)}/preview`;
return (
<div
className="group relative flex flex-col overflow-hidden rounded-2xl transition-all hover:-translate-y-0.5"
style={{
background: "var(--surface)",
border: "1px solid var(--line-soft)",
boxShadow: "0 1px 0 var(--line-faint), 0 14px 32px -22px rgba(21,20,15,0.18)",
}}
>
{/* live HTML thumbnail (scaled-down iframe pointing at /api/.../preview) */}
<div
className="relative h-44 overflow-hidden"
style={{ background: "var(--paper)", borderBottom: "1px solid var(--line-faint)" }}
>
<div
className="absolute origin-top-left"
style={{
transform: "scale(0.32)",
width: "calc(100% / 0.32)",
height: "calc(176px / 0.32)",
pointerEvents: "none",
}}
>
<iframe
title={tpl.id}
src={previewUrl}
sandbox=""
loading="lazy"
className="block h-full w-full"
style={{ background: "#fff", border: "none" }}
/>
</div>
<div className="absolute left-3 top-3 flex items-center gap-1.5">
<span
className="rounded-full bg-white/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide backdrop-blur"
style={{ border: "1px solid var(--line-faint)", color: "var(--ink)" }}
>
{tpl.emoji} {locale === "en" ? tpl.enName : tpl.zhName}
</span>
{loaded && (
<span
className="rounded-full px-2 py-0.5 text-[10px] font-semibold backdrop-blur"
style={{ background: "rgba(31,122,58,0.15)", color: "var(--green)" }}
title={t("samples.loadedTooltip")}
>
{t("samples.loaded")}
</span>
)}
</div>
<div className="absolute right-3 bottom-3 rounded-full bg-white/90 px-2 py-0.5 text-[10px] font-mono tracking-wide backdrop-blur" style={{ border: "1px solid var(--line-faint)", color: "var(--ink-mute)" }}>
{tpl.aspectHint}
</div>
</div>
<div className="flex flex-1 flex-col gap-2 p-4">
<div>
<div className="font-semibold text-[14px] text-[var(--ink)] leading-snug">
{example?.name || (locale === "en" ? tpl.enName : tpl.zhName)}
</div>
{example?.tagline && (
<div className="text-[11.5px] text-[var(--coral)] mt-0.5">{example.tagline}</div>
)}
</div>
<div className="text-[11.5px] text-[var(--ink-mute)] leading-snug">
{example?.desc || tpl.description}
</div>
{example?.source && (
<a
href={example.source.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 self-start rounded-full px-2 py-0.5 text-[10.5px] font-medium transition-colors"
style={{
background: "var(--paper)",
color: "var(--ink-mute)",
border: "1px solid var(--line-faint)",
}}
title={example.source.url}
>
<span></span>
<span>{t("samples.sourceLink", { label: example.source.label })}</span>
</a>
)}
<div className="flex items-center justify-between gap-2 pt-1.5 mt-auto">
<span
className="min-w-0 truncate text-[10px] uppercase tracking-[0.14em] text-[var(--ink-faint)]"
title={`${tpl.scenario} · ${example?.format ?? "html"}`}
>
{tpl.scenario} · {example?.format ?? "html"}
</span>
<button
onClick={onLoad}
disabled={loading}
className="shrink-0 whitespace-nowrap inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[12px] font-semibold transition-all disabled:opacity-50"
style={{
background: "var(--coral)",
color: "#fff",
boxShadow: "0 8px 18px -12px rgba(201,100,66,0.85)",
}}
>
{loading ? t("samples.loadingButton") : t("samples.loadButton")}
</button>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More