@@ -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
|
||||
@@ -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/
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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] + 我们的扩展 frontmatter(picker 会读这部分)。**不需要在任何地方注册。** 把文件夹放进去,重启 `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` 内联 CSS;PNG 用 `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 commit;merge 时我们会 squash。
|
||||
- **不要 force-push 共享分支**,除非 reviewer 让你做。
|
||||
|
||||
我们不强制 CLA。Apache-2.0 已经覆盖;你的贡献以同一份协议授权。
|
||||
|
||||
---
|
||||
|
||||
## 报 bug
|
||||
|
||||
开 issue 时请附:
|
||||
|
||||
- 你跑的命令(确切的 `pnpm dev` 调用,或者你点的 UI 按钮)。
|
||||
- 选的是哪个 agent CLI(Claude 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
|
||||
@@ -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.
|
||||
@@ -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 agent(spawn + 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
|
||||
都勾掉确保能够完成跑通直接可以使用,我明天早上过来检查你的任务
|
||||
@@ -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 — 6–10 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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`nexu-io/html-anything`
|
||||
- 原始仓库:https://github.com/nexu-io/html-anything
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -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="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="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 兼容的视频脚本 —— 6–10 个连续 <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 CLI(8 个)** | 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` 走 SSE,agent 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> —— 按 mode(prototype / deck / frame / social / office / doc)与 scenario(design / 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 Canvas(1920×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,业务进程不直接 spawn;CLI 的 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 server,picker 里就会出现;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 server,picker 里就出现。`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-line,server 抽出其中的 `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`,以它为准。
|
||||
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
@@ -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 订阅。转换过程中会显示动画进度指示器,展示已接收的文本块数和耗时。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("极简简历");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
`;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
import { main } from "./index.js";
|
||||
main(process.argv.slice(2));
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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: "无法确定主题,使用默认模板",
|
||||
};
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
|
After Width: | Height: | Size: 2.0 MiB |
@@ -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>
|
||||
|
After Width: | Height: | Size: 624 KiB |
|
After Width: | Height: | Size: 619 KiB |
|
After Width: | Height: | Size: 618 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 800 KiB |
|
After Width: | Height: | Size: 904 KiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 833 KiB |
|
After Width: | Height: | Size: 474 KiB |
|
After Width: | Height: | Size: 487 KiB |
|
After Width: | Height: | Size: 578 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 700 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 386 KiB |
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 60–90 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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ``.
|
||||
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: `})
|
||||
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||