# 接单版 · 建站引导（给 AI 编程助手）

> **这是「接单版」**：做一个你自己用的站——一个 API Key、一个站、一个人干活。
> 想把服务卖给客户（多用户各用各的额度），用「代理版」那一份。
>
> **怎么用**：整页复制（Ctrl+A / Ctrl+C），粘给你的 AI 编程助手（任意具备写代码能力的 AI 助手）。
> 它会给你生成一个 `npm i && npm start`（或 `pip install -r requirements.txt && python server.py`）
> 就能跑起来的完整网站。**不需要你再补任何背景。**

---

## 一、你的任务

你是一个全栈开发者。请做一个**自己用的 AI 内容生成网站**：

- 用户在你的网站上输入需求 → 调用「本平台」的 API 生成内容（剧本 / 提示词 / 图片 / 视频）
- 网站上能看到任务进度、生成结果、播放下载
- 显示点数余额

**你不实现 AI 模型本身**，模型能力全部来自对方 API，你只做「界面 + 转发」。

---

## 二、技术栈（两套选一套，别自创）

| 方案 | 后端 | 前端 | 启动 |
|---|---|---|---|
| **A（推荐）** | Node.js + Express | 原生 HTML + JS | `npm i && npm start` |
| **B** | Python + FastAPI | 原生 HTML + JS | `pip install -r requirements.txt && python server.py` |

**禁止**：React / Vue / Next.js / Webpack / Vite / TypeScript / 任何构建步骤。
前端就是两个文件：`index.html` + `app.js`，由后端静态托管。

---

## 三、鉴权（最重要的一条）

所有请求带 API Key：

```
Authorization: Bearer sk-live-xxxxxxxx
```

**API Key 只能放在后端**，绝对不能出现在前端 JS、HTML、或任何浏览器能拿到的位置。

```
浏览器 ──→ 你的后端 ──(带上 Key)──→ 本平台 API
```

Key 从环境变量读：`.env` 里的 `API_KEY`。

---

### Key 丢了 / 要换一把怎么办

1. 打开网页 →「我的」→ 找到那把 key → 点「吊销」
2. 点「创建 API Key」→ 复制新生成的（只显示一次）
3. 改你项目 `.env` 里的 `API_KEY` → 重启服务

**账户点数、代理身份、历史用量都不受影响**，换 key 只是换一把钥匙。

---

## 四、接口清单（自包含，不用另找文档）

Base URL 从环境变量读：`API_BASE`，形如 `https://你的域名/v1`。

以下 `{BASE}` 均指这个地址。

### 4.1 能力清单（启动时调一次）

```
GET {BASE}/caps
Authorization: Bearer sk-live-xxx
```

```json
{
  "kinds": {
    "script":     { "group": "prompt", "sync": true,  "available": true, "points": 18 },
    "video":      { "group": "prompt", "sync": true,  "available": true, "points": 22 },
    "storyboard": { "group": "prompt", "sync": true,  "available": true, "points": 15 },
    "drama":      { "group": "prompt", "sync": true,  "available": true, "points": 54 },
    "fight":      { "group": "prompt", "sync": true,  "available": true, "points": 54 },
    "edit":       { "group": "prompt", "sync": true,  "available": true, "points": 11 },
    "t2i":        { "group": "image",  "sync": false, "available": true, "points": 12 },
    "reverse":    { "group": "image",  "sync": false, "available": true, "points": 14 },
    "drama_gen":  { "group": "video",  "sync": false, "available": true, "points": 90,
                    "sec_range": [4, 120] },
    "720P":  { "points_per_sec": 34 },
                      "1080P": { "points_per_sec": 65 }
                    },
                    "require": "prompt_job" }
  },
  "is_agent": false,
  "balance": 4800,
  "user": { "id": 42, "name": "xxx" }
}
```

**前端的功能列表和价格必须从这个接口动态渲染，不许写死。**

### 4.2 每个 kind 的 input 结构（照抄）

| kind | input | 同步 | 说明 |
|---|---|---|---|
| `script` | `{"text":"需求描述"}` | ✅ | 写剧本 |
| `video` | `{"text":"剧本或分镜内容"}` | ✅ | 视频提示词 |
| `storyboard` | `{"text":"故事内容"}` | ✅ | 分镜提示词 |
| `drama` | `{"text":"剧情内容"}` | ✅ | 文戏提示词 |
| `fight` | `{"text":"动作内容"}` | ✅ | 武戏提示词 |
| `edit` | `{"text":"改图需求","asset":"ast_xxx","model":"模型A"}` | ✅ | 改图提示词，**`asset` 必填** |
| `t2i` | `{"text":"画面描述"}` | ❌ | 文生图 |
| `reverse` | `{"asset":"ast_xxx"}` | ❌ | 图片反推，`asset` **必填** |
| `drama_gen` | `{"text":"故事梗概","target_sec":30}` | ❌ | 短剧生成 |

**关于「两步」类功能**：`/caps` 里如果某个 kind 带 `require` 字段（比如 `"require": "prompt_job"`），
说明它必须分两步：先调对应的「写提示词」功能拿到任务 id，再把这个 id 作为 `input.prompt_job` 传进去。
少了会返回 400。具体是哪个 kind，看 caps 里哪个有 `require` —— 代码里不要写死。

**关于 `edit` 的 `model`**：`模型A` 和 `模型B` 的提示词格式不同（`图1` vs `<image1>`），
不传就走服务端默认。给普通用户做界面时可以不暴露这个参数。

### 4.3 创建任务

```
POST {BASE}/jobs
Content-Type: application/json

{
  "kind": "script",
  "input": { "text": "用户输入的完整需求" },
  "wait": 45
}
```

可选头：`Idempotency-Key: <任意字符串>`——24 小时内同一个值重放会返回同一个任务，不重复扣点。

`wait`：0 = 立即返回；1~60 = 最多等这么多秒。

**同步功能返回：**

```json
{ "id": "20261104_180521_747680", "kind": "script", "status": "done",
  "points": 17, "result": { "text": "生成的内容" } }
```

**异步功能返回：**

```json
{ "id": "20261104_180742_0783c6", "kind": "<你传的 kind>", "status": "queued", "points": 100 }
```

### 4.4 查询任务

```
GET {BASE}/jobs/{id}
```

```json
{
  "id": "20261104_180742_0783c6",
  "kind": "<你传的 kind>",
  "status": "done",
  "points": 100,
  "result": {
    "text": "已出片：1 个文件 · 1 段 · 扣 100 点",
    "videos": [
      { "url": "/v1/files/20261104_180742_0783c6/0",
        "type": "video", "name": "xxx_seg01.mp4", "bytes": 1162369 }
    ],
    "images": []
  }
}
```

`status`：`queued` / `running` / `done` / `failed` / `canceled`
失败时多一个 `error` 字段，**失败不扣点**。

### 4.5 下载产物（关键）

`result` 里的 `url` 是**相对路径**，要拼上域名，而且**必须带 key 当 query**——
因为 `<img>` / `<video>` 标签加不了请求头。

```
https://你的域名/v1/files/xxx/0?key=sk-live-xxxx
```

⚠️ **这个 URL 必须由后端拼好再返回给前端**，绝不能在前端拼（否则 Key 泄露）。

### 4.6 余额

```
GET {BASE}/balance
→ { "points": 4800, "today_used": 0, "day_limit": 0 }
```

### 4.7 上传素材

```
POST {BASE}/assets
Content-Type: application/json

{ "data": "data:image/png;base64,iVBORw0...", "name": "参考图" }
→ { "id": "ast_dd0ff6857e", "name": "参考图", "media_type": "image", "bytes": 139982 }
```

拿到 `id` 后作为 `asset` 传给 `reverse` / `t2i`  `edit`。

---

## 五、界面要做成什么样

```
┌──────────────────────────────────────────┐
│ 余额：4,800 点                  [刷新]    │
├──────────────────────────────────────────┤
│ 功能：[下拉框，从 /caps 动态生成]          │
│                                          │
│ ┌──────────────────────────────────────┐ │
│ │ （多行输入框，写需求）                 │ │
│ └──────────────────────────────────────┘ │
│ [开始生成]                                │
├──────────────────────────────────────────┤
│ 任务列表                                  │
│ ┌──────────────────────────────────────┐ │
│ │ 写剧本 · 生成中…  已等 12 秒           │ │
│ ├──────────────────────────────────────┤ │
│ │ 文生图 · 完成     [查看] [下载]        │ │
│ ├──────────────────────────────────────┤ │
│ │ 出片 · 失败：内容被拒  （未扣点）        │ │
│ └──────────────────────────────────────┘ │
└──────────────────────────────────────────┘
```

### 必做行为（9 条）

| # | 行为 |
|---|---|
| 1 | 页面加载 → `GET /caps` 渲染功能下拉 + `GET /balance` 显示余额 |
| 2 | 点「开始生成」→ `POST /api/create` |
| 3 | `sync: true` 的功能直接用返回的 `result.text` 渲染 |
| 4 | `sync: false` 的功能每 **5 秒** 轮询 `/api/job/{id}`，最多 10 分钟 |
| 5 | 轮询期间显示 `queued`/`running` 和已等待秒数 |
| 6 | 文本 → `<p>`；图片 → `<img>`；视频 → `<video controls>` |
| 7 | 失败 → 显示 `error` 并标注「本次未扣点」 |
| 8 | 每个任务结束刷新一次余额 |
| 9 | 后端返回 402 → 提示「点数不足，请联系管理员充值」 |

### 不要做

- ❌ 用户注册 / 登录（一个站点一个使用者，Key 在环境变量里）
- ❌ 支付（点数由管理员后台充）
- ❌ 数据库（任务存内存，刷新丢失没关系）

---

## 六、你的后端要给前端提供这 5 个接口

| 方法 | 路径 | 作用 |
|---|---|---|
| POST | `/api/create` | `{kind, text, asset?}` → 转发 `/jobs` |
| GET | `/api/job/:id` | 转发 `/jobs/{id}`，**顺便把产物 URL 拼好** |
| GET | `/api/caps` | 转发 `/caps` |
| GET | `/api/balance` | 转发 `/balance` |
| POST | `/api/upload` | 收 base64 → 调 `/assets` |

---

### 关于 `edit`（改图提示词）必须上传图片

**改图 = 针对一张已有的图写改写指令**，所以 `input.asset` 是必填的。

```bash
# 1) 先上传要改的图
POST /v1/assets
{ "data": "data:image/png;base64,...", "name": "参考图.png" }
→ { "id": "ast_xxx", ... }

# 2) 再调 edit，把 id 传进 asset
POST /v1/jobs
{ "kind": "edit", "input": { "text": "把背景换成雨夜霓虹街头", "asset": "ast_xxx" } }
→ { "status": "done", "result": { "text": "把图1的背景整体替换为…" } }
```

**不传 `asset` 会返回 400**：

```json
{ "detail": { "code": "invalid_input",
              "message": "改图提示词需要先上传参考图：POST /v1/assets 拿 asset id，再把它作为 input.asset 传进来。只想从零写画面描述用 t2i。" } }
```

**想「从零描述画面」不要图，用 `t2i`（文生图），不是 `edit`。**

| 需求 | 用哪个 kind | 要不要图 |
|---|---|---|
| 从零写画面描述 / 生成图片 | `t2i` | ❌ 不用 |
| 针对已有图写改写指令 | `edit` | ✅ **必须** |
| 看图倒推提示词 | `reverse` | ✅ 必须 |

---

## 七、错误处理

**上游的错误体是 `{ "detail": { "code": "...", "message": "..." } }`** —— 你的后端必须先解包，
再按下面这个结构返回给前端。直接把上游 JSON 透传过去，前端读 `d.message` 会拿到 `undefined`，
只能显示一个「失败」两字（这是 AI 生成代码时最常踩的坑，参考实现里已经处理好了）。

后端统一转成：

```json
{ "ok": false, "code": "insufficient_points", "message": "点数不足，需要 100 点" }
```

| 上游 HTTP | code | 前端提示 |
|---|---|---|
| 400 | `invalid_input` | 显示 `message` |
| 401 | `invalid_api_key` | 「API Key 无效，请检查 .env」 |
| 402 | `insufficient_points` | 「点数不足，请联系管理员充值」 |
| 429 | `rate_limited` | 「请求太频繁，5 秒后重试」 |
| 501 | `not_ready` | 「该功能暂未开放」 |
| 5xx | `upstream_error` | 「服务暂时不可用，请稍后再试」 |

---

## 八、安全红线（4 条，必须遵守）

1. **API Key 只在后端**，不进前端、不进日志、不进 git
2. **产物 URL 在后端拼 `?key=`**，前端拿到的是完整地址
3. 需求文本长度限制 **8000 字符**以内（后端校验）
4. 你的 `/api/*` 至少加一层简单保护（访问密码 / IP 白名单），别做成公开代理

---

## 九、验收标准（9 条，全满足才算完成）

- [ ] `npm i && npm start`（或 `pip install -r requirements.txt && python server.py`）后能打开页面
- [ ] 余额正确显示
- [ ] 功能列表从 `/caps` 动态渲染，不是写死的
- [ ] 写剧本能出结果（同步）
- [ ] 文生图能出图并显示（异步 + 轮询）
- [ ] 出片能播放视频（异步 + 轮询 + `<video>`）
- [ ] 失败时显示原因并提示「未扣点」
- [ ] **F12 → 网络面板里看不到任何带 `sk-live-` 的请求**
- [ ] 刷新页面后余额是最新的
- [ ] **点开产物链接能直接下载**（不是 404、不是白屏；图片能在 `<img>` 里显示）

---

## 十、目录结构

```
my-site/
├── .env
├── package.json          （Node 版）
├── server.js             （Node 版）
├── requirements.txt      （Python 版）
├── server.py             （Python 版）
└── public/
    ├── index.html
    └── app.js
```

---

# 十一、完整代码 · Node 版

### 11.1 `package.json`

```json
{
  "name": "doubao-site",
  "version": "1.0.0",
  "private": true,
  "type": "commonjs",
  "scripts": { "start": "node server.js" },
  "dependencies": {
    "dotenv": "^16.4.5",
    "express": "^4.19.2"
  }
}
```

### 11.2 `.env`

```
API_BASE=https://你的域名/v1
API_KEY=sk-live-xxxxxxxxxxxxxxxx
PORT=3000
ACCESS_PASSWORD=改成你自己的访问密码
```

### 11.3 `server.js`

```js
const express = require('express');
const path = require('path');
require('dotenv').config();

const BASE = (process.env.API_BASE || '').replace(/\/+$/, '');
const KEY = process.env.API_KEY || '';
const PORT = Number(process.env.PORT || 3000);
const PASS = process.env.ACCESS_PASSWORD || '';

if (!BASE || !KEY) {
  console.error('缺少 API_BASE 或 API_KEY，检查 .env');
  process.exit(1);
}

const app = express();
app.use(express.json({ limit: '30mb' }));
app.use(express.static(path.join(__dirname, 'public')));

// 站点主域名（去掉末尾的 /v1），用来拼产物地址
const ORIGIN = BASE.replace(/\/v1\/?$/, '');

// 极简保护：带 ?pw=xxx 或 x-pass 头才能调 /api/*
app.use('/api', (req, res, next) => {
  if (!PASS) return next();
  const pw = req.query.pw || req.headers['x-pass'] || '';
  if (pw === PASS) return next();
  res.status(401).json({ ok: false, code: 'need_password', message: '需要访问密码' });
});

async function upstream(pathname, opt = {}) {
  const r = await fetch(BASE + pathname, {
    ...opt,
    headers: {
      'Authorization': 'Bearer ' + KEY,
      'Content-Type': 'application/json',
      ...(opt.headers || {}),
    },
  });
  let body = {};
  try { body = await r.json(); } catch (_) {}
  return { status: r.status, body };
}

// 把 result 里的相对 url 拼成可直接播放的绝对地址（key 后端拼，不进前端）
function fixResult(j) {
  const res = j.result || {};
  const fix = (list) => (list || []).map((x) => ({
    ...x,
    url: ORIGIN + x.url + '?key=' + encodeURIComponent(KEY),
  }));
  if (Array.isArray(res.videos)) res.videos = fix(res.videos);
  if (Array.isArray(res.images)) res.images = fix(res.images);
  return { ...j, result: res };
}

app.get('/api/caps', async (_req, res) => {
  const r = await upstream('/caps');
  res.status(r.status).json(r.body);
});

app.get('/api/balance', async (_req, res) => {
  const r = await upstream('/balance');
  res.status(r.status).json(r.body);
});

app.post('/api/create', async (req, res) => {
  const { kind, text, asset, prompt_job, sec, resolution } = req.body || {};
  if (!kind) return res.status(400).json({ ok: false, code: 'invalid_input', message: '缺少 kind' });

  const input = {};
  if (text != null) {
    const t = String(text);
    if (t.length > 8000) {
      return res.status(400).json({ ok: false, code: 'invalid_input', message: '需求太长（≤8000 字）' });
    }
    input.text = t;
    // 文本字段一律用 text
  }
  if (asset) input.asset = asset;
  if (prompt_job) input.prompt_job = prompt_job;
  if (sec) input.sec = Number(sec);
  if (resolution) input.resolution = resolution;

  const r = await upstream('/jobs', {
    method: 'POST',
    body: JSON.stringify({ kind, input, wait: 45 }),
  });
  if (r.status !== 200) {
    // 上游的错误体是 { "detail": { "code": "...", "message": "..." } }，
    // 必须解包再返回，否则前端读 d.message 会读不到（这是最容易踩的一个坑）
    const det = (r.body && r.body.detail) || {};
    return res.status(r.status).json({
      ok: false,
      code: det.code || 'upstream_error',
      message: det.message || ('HTTP ' + r.status),
    });
  }
  res.json(r.body);
});

app.get('/api/job/:id', async (req, res) => {
  const r = await upstream('/jobs/' + encodeURIComponent(req.params.id));
  if (r.status !== 200) {
    const det = (r.body && r.body.detail) || {};
    return res.status(r.status).json({
      ok: false,
      code: det.code || 'upstream_error',
      message: det.message || ('HTTP ' + r.status),
    });
  }
  res.json(fixResult(r.body));
});

app.post('/api/upload', async (req, res) => {
  const data = (req.body || {}).data;
  if (!data) return res.status(400).json({ ok: false, code: 'invalid_input', message: '缺少 data' });
  const r = await upstream('/assets', {
    method: 'POST',
    body: JSON.stringify({ data, name: (req.body && req.body.name) || '参考图' }),
  });
  res.status(r.status).json(r.body);
});

app.listen(PORT, () => {
  console.log('已启动：http://localhost:' + PORT);
  if (PASS) console.log('访问密码：' + PASS + '（浏览器里带上 ?pw=' + PASS + '）');
});
```

### 11.4 `public/index.html`

```html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>AI 内容生成</title>
<style>
  *{box-sizing:border-box}
  body{max-width:780px;margin:24px auto;padding:0 16px;
       font:15px/1.7 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;
       background:#f7f8fa;color:#1f2937}
  h2{margin:0 0 12px}
  .card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:16px;margin-bottom:14px}
  .row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
  .grow{flex:1}
  textarea{width:100%;height:120px;padding:10px;font:inherit;border:1px solid #d1d5db;
           border-radius:9px;resize:vertical}
  select,input{padding:8px;font:inherit;border:1px solid #d1d5db;border-radius:8px}
  button{padding:9px 18px;font:inherit;border:none;border-radius:9px;background:#2563eb;
         color:#fff;cursor:pointer}
  button:disabled{background:#9ca3af;cursor:not-allowed}
  button.ghost{background:#f3f4f6;color:#374151}
  .muted{color:#6b7280;font-size:13px}
  .item{border:1px solid #e5e7eb;border-radius:10px;padding:12px;margin:10px 0;background:#fff}
  .item.fail{border-color:#fecaca;background:#fef2f2}
  .item .hd{display:flex;justify-content:space-between;gap:10px;font-size:14px}
  pre{white-space:pre-wrap;word-break:break-word;background:#f9fafb;border:1px solid #eef0f3;
      border-radius:8px;padding:10px;font:13px/1.7 ui-monospace,Consolas,monospace;margin:8px 0 0}
  img,video{max-width:100%;border-radius:8px;margin-top:8px;display:block}
  .pill{font-size:12px;padding:2px 8px;border-radius:999px;background:#eef2ff;color:#4338ca}
  .pill.ok{background:#ecfdf5;color:#047857}
  .pill.bad{background:#fef2f2;color:#b91c1c}
</style>
</head>
<body>
  <h2>AI 内容生成</h2>

  <div class="card">
    <div class="row">
      <span>余额：<b id="bal">—</b> 点</span>
      <span class="muted" id="who"></span>
      <span class="grow"></span>
      <button class="ghost" id="refresh">刷新</button>
    </div>
  </div>

  <div class="card">
    <div class="row" style="margin-bottom:10px">
      <label>功能</label>
      <select id="kind"></select>
      <span class="muted" id="kindTip"></span>
    </div>

    <div class="row" style="margin-bottom:10px;display:none" id="secRow">
      <label>时长</label>
      <input id="sec" type="number" value="5" min="2" max="30" style="width:90px">
      <label>分辨率</label>
      <select id="res">
        <option value="480P">480P</option>
        <option value="720P">720P</option>
        <option value="1080P">1080P</option>
      </select>
    </div>

    <textarea id="text" placeholder="写清楚你要什么，比如：一个外卖骑手在暴雨里接单，到了发现客户是前女友"></textarea>

    <div class="row" style="margin-top:10px">
      <button id="go">开始生成</button>
      <span class="muted" id="tip"></span>
    </div>
  </div>

  <div id="list"></div>

<script src="/app.js"></script>
</body>
</html>
```

### 11.5 `public/app.js`

```js
const $ = (s) => document.querySelector(s);
const PW = new URLSearchParams(location.search).get('pw') || '';
const Q = PW ? '?pw=' + encodeURIComponent(PW) : '';

let CAPS = null;
const TASKS = new Map();   // id -> { kind, status, text, waited, timer }

function esc(s) {
  return String(s == null ? '' : s).replace(/[&<>"']/g, (c) =>
    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}

async function api(path, opt = {}) {
  const o = { ...opt };
  if (o.body && typeof o.body !== 'string') {
    o.body = JSON.stringify(o.body);
    o.headers = { 'Content-Type': 'application/json', ...(o.headers || {}) };
  }
  const r = await fetch(path + Q, o);
  let d = {};
  try { d = await r.json(); } catch (_) {}
  if (!r.ok) {
    const e = new Error((d && (d.message || d.detail)) || ('HTTP ' + r.status));
    e.code = (d && d.code) || '';
    e.status = r.status;
    throw e;
  }
  return d;
}

async function loadCaps() {
  const d = await api('/api/caps');
  CAPS = d;
  $('#bal').textContent = d.balance != null ? d.balance : '—';
  if ($('#who')) $('#who').textContent = d.is_agent ? '（代理账户）' : '';

  const sel = $('#kind');
  sel.innerHTML = '';
  Object.entries(d.kinds || {}).forEach(([k, v]) => {
    if (v.available === false) return;
    const opt = document.createElement('option');
    opt.value = k;
    opt.textContent = label(k) + (v.sync ? '' : '（较慢）');
    sel.appendChild(opt);
  });
  onKindChange();
}

function label(k) {
  return {
    script: '写剧本', video: '视频提示词', storyboard: '分镜提示词',
    drama: '文戏提示词', fight: '武戏提示词', edit: '改图提示词',
    t2i: '文生图',
    reverse: '图片反推', drama_gen: '短剧生成', }[k] || k;
}

function onKindChange() {
  const k = $('#kind').value;
  const v = (CAPS && CAPS.kinds && CAPS.kinds[k]) || {};
  const _kk = (CAPS.kinds[k] || {});
  const needPrompt = !!(_kk.require);
  $('#secRow').style.display = (v.sec_range || v.resolutions) ? 'flex' : 'none';
  let tip = '';
  if (v.sync === false) tip = '这个功能要跑一会儿，提交后会显示进度';
  if (v.require) tip = '这个功能要先写提示词（页面会自动处理）';
  if (k === 'reverse') tip = '需要先上传参考图（这个示例未接上传）';
  $('#kindTip').textContent = tip;
}

async function refreshBal() {
  try {
    const d = await api('/api/balance');
    $('#bal').textContent = d.points != null ? d.points : '—';
  } catch (_) {}
}

function render() {
  const box = $('#list');
  if (!TASKS.size) { box.innerHTML = '<div class="muted">还没有任务</div>'; return; }
  box.innerHTML = Array.from(TASKS.entries()).map(([id, t]) => {
    const st = t.status;
    const pill = st === 'done' ? '<span class="pill ok">完成</span>'
      : st === 'failed' ? '<span class="pill bad">失败</span>'
      : '<span class="pill">' + esc(st === 'queued' ? '排队中' : '生成中') + ' ' + t.waited + ' 秒</span>';

    let body = '';
    if (t.error) {
      body = '<pre style="color:#b91c1c">' + esc(t.error) + '</pre>'
        + '<div class="muted" style="margin-top:6px">本次未扣点</div>';
    } else if (t.result) {
      if (t.result.text) body += '<pre>' + esc(t.result.text) + '</pre>';
      (t.result.images || []).forEach((x) => { body += '<img src="' + esc(x.url) + '" alt="">'; });
      (t.result.videos || []).forEach((x) => {
        body += '<video controls src="' + esc(x.url) + '"></video>';
      });
    }
    return '<div class="item' + (st === 'failed' ? ' fail' : '') + '">'
      + '<div class="hd"><b>' + esc(label(t.kind)) + '</b>' + pill + '</div>'
      + body + '</div>';
  }).join('');
}

function poll(id) {
  const t = TASKS.get(id);
  if (!t || t.timer) return;
  t.timer = setInterval(async () => {
    const cur = TASKS.get(id);
    if (!cur) return;
    cur.waited += 5;
    if (cur.waited > 600) {
      clearInterval(cur.timer); cur.timer = null;
      cur.status = 'failed'; cur.error = '等待超时（10 分钟）';
      render(); return;
    }
    try {
      const d = await api('/api/job/' + encodeURIComponent(id));
      cur.status = d.status || cur.status;
      cur.points = d.points;
      if (d.status === 'done') {
        cur.result = d.result || {};
        clearInterval(cur.timer); cur.timer = null;
        refreshBal();
      } else if (d.status === 'failed' || d.status === 'canceled') {
        cur.error = d.error || '生成失败';
        clearInterval(cur.timer); cur.timer = null;
        refreshBal();
      }
    } catch (e) {
      cur.status = 'failed';
      cur.error = e.message;
      clearInterval(cur.timer); cur.timer = null;
    }
    render();
  }, 5000);
  render();
}

async function generate() {
  const kind = $('#kind').value;
  const text = $('#text').value.trim();
  if (!text) return alert('先写需求');
  $('#go').disabled = true;
  $('#tip').textContent = '提交中…';
  try {
    const body = { kind, text };
    if (!!((CAPS.kinds[kind] || {}).sec_range || (CAPS.kinds[kind] || {}).resolutions)) body.sec = Number($('#sec').value || 5);
    if ((caps.kinds[kind] || {}).resolutions) {
      body.resolution = $('#res').value;
      // 两步：先写提示词
      $('#tip').textContent = '第 1 步：生成提示词…';
      // 两步解析：caps 里带 require 的 kind，要先调「写提示词」那个 kind
      const _promptKind = Object.keys(CAPS.kinds).find(k2 => k2 === String(kind).replace(/_gen$/, '') + '_prompt')
        || Object.keys(CAPS.kinds).find(k2 => k2.endsWith('_prompt'));
      const p = await api('/api/create', {
        method: 'POST',
        body: { kind: _promptKind, text, sec: body.sec, resolution: body.resolution },
      });
      let promptJob = p;
      while (promptJob.status !== 'done' && promptJob.status !== 'failed') {
        await new Promise((r) => setTimeout(r, 3000));
        promptJob = await api('/api/job/' + encodeURIComponent(promptJob.id));
      }
      if (promptJob.status === 'failed') throw new Error(promptJob.error || '提示词生成失败');
      body.prompt_job = promptJob.id;
      body.text = (promptJob.result && promptJob.result.text) || text;
      $('#tip').textContent = '第 2 步：出片…';
    }

    const d = await api('/api/create', { method: 'POST', body });
    const id = d.id;
    if (!id) throw new Error('没有返回任务 id');

    TASKS.set(id, {
      kind: d.kind || kind,
      status: d.status || 'queued',
      waited: 0,
      result: d.status === 'done' ? (d.result || {}) : null,
      error: d.status === 'failed' ? (d.error || '失败') : '',
      timer: null,
    });

    if (d.status === 'done') {
      refreshBal();
      render();
    } else if (d.status === 'failed') {
      render();
    } else {
      poll(id);
    }
    $('#tip').textContent = '已提交';
  } catch (e) {
    $('#tip').textContent = '';
    if (e.status === 402) alert('点数不足，请联系管理员充值');
    else if (e.status === 401) alert('API Key 无效，请检查 .env');
    else if (e.status === 429) alert('请求太频繁，请稍后再试');
    else alert('失败：' + e.message);
  } finally {
    $('#go').disabled = false;
  }
}

$('#go').onclick = generate;
$('#refresh').onclick = () => loadCaps().catch((e) => alert(e.message));
$('#kind').onchange = onKindChange;

loadCaps().catch((e) => {
  $('#tip').textContent = '加载失败：' + e.message;
});
```

---

# 十二、完整代码 · Python 版

前端和第十一节完全一样（`public/index.html` + `public/app.js`），只换后端。

### 12.1 `requirements.txt`

```
fastapi==0.115.0
uvicorn[standard]==0.30.6
httpx==0.27.2
python-dotenv==1.0.1
```

### 12.2 `.env`

同 Node 版：

```
API_BASE=https://你的域名/v1
API_KEY=sk-live-xxxxxxxxxxxxxxxx
PORT=3000
ACCESS_PASSWORD=改成你自己的访问密码
```

### 12.3 `server.py`

```python
import os
import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from dotenv import load_dotenv

load_dotenv()
BASE = (os.environ.get("API_BASE") or "").rstrip("/")
KEY = os.environ.get("API_KEY") or ""
PORT = int(os.environ.get("PORT") or 3000)
PASS = os.environ.get("ACCESS_PASSWORD") or ""

if not BASE or not KEY:
    raise SystemExit("缺少 API_BASE 或 API_KEY，检查 .env")

ORIGIN = BASE[:-3] if BASE.endswith("/v1") else BASE

app = FastAPI()


async def upstream(path: str, method: str = "GET", body=None):
    headers = {"Authorization": "Bearer " + KEY, "Content-Type": "application/json"}
    async with httpx.AsyncClient(timeout=180) as c:
        r = await c.request(method, BASE + path, headers=headers, json=body)
    try:
        data = r.json()
    except Exception:
        data = {}
    return r.status_code, data


def fix_result(j: dict) -> dict:
    res = j.get("result") or {}
    for k in ("videos", "images"):
        if isinstance(res.get(k), list):
            for x in res[k]:
                u = x.get("url") or ""
                x["url"] = ORIGIN + u + "?key=" + KEY
    j["result"] = res
    return j


def need_pass(request: Request):
    if not PASS:
        return
    pw = request.query_params.get("pw") or request.headers.get("x-pass") or ""
    if pw != PASS:
        raise HTTPException(401, detail={"code": "need_password", "message": "需要访问密码"})


@app.get("/api/caps")
async def caps(request: Request):
    need_pass(request)
    st, d = await upstream("/caps")
    return JSONResponse(d, status_code=st)


@app.get("/api/balance")
async def balance(request: Request):
    need_pass(request)
    st, d = await upstream("/balance")
    return JSONResponse(d, status_code=st)


@app.post("/api/create")
async def create(request: Request):
    need_pass(request)
    b = await request.json()
    kind = (b or {}).get("kind")
    if not kind:
        raise HTTPException(400, detail={"code": "invalid_input", "message": "缺少 kind"})

    inp = {}
    text = (b or {}).get("text")
    if text is not None:
        t = str(text)
        if len(t) > 8000:
            raise HTTPException(400, detail={"code": "invalid_input", "message": "需求太长（≤8000 字）"})
        inp["text"] = t
        # 文本字段统一用 text（平台两种都认）
    for k in ("asset", "prompt_job", "resolution"):
        if (b or {}).get(k):
            inp[k] = b[k]
    if (b or {}).get("sec"):
        inp["sec"] = float(b["sec"])

    st, d = await upstream("/jobs", "POST", {"kind": kind, "input": inp, "wait": 45})
    if st != 200:
        # 上游错误体是 {"detail": {"code": ..., "message": ...}}，要解包
        det = (d or {}).get("detail") or {}
        return JSONResponse({"ok": False,
                             "code": det.get("code") or "upstream_error",
                             "message": det.get("message") or ("HTTP %d" % st)},
                            status_code=st)
    return d


@app.get("/api/job/{jid}")
async def job(jid: str, request: Request):
    need_pass(request)
    st, d = await upstream("/jobs/" + jid)
    if st != 200:
        det = (d or {}).get("detail") or {}
        return JSONResponse({"ok": False,
                             "code": det.get("code") or "upstream_error",
                             "message": det.get("message") or ("HTTP %d" % st)},
                            status_code=st)
    return fix_result(d)


@app.post("/api/upload")
async def upload(request: Request):
    need_pass(request)
    b = await request.json()
    data = (b or {}).get("data")
    if not data:
        raise HTTPException(400, detail={"code": "invalid_input", "message": "缺少 data"})
    st, d = await upstream("/assets", "POST",
                           {"data": data, "name": (b or {}).get("name") or "参考图"})
    return JSONResponse(d, status_code=st)


app.mount("/", StaticFiles(directory="public", html=True), name="static")

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=PORT)
```

---

## 十三、AI 最容易犯的 8 个错（自查清单）

| # | 错误 | 正确做法 |
|---|---|---|
| 1 | 把 API Key 写进前端 JS | Key 只在后端，前端调你自己的 `/api/*` |
| 2 | 前端直接拼 `?key=` | 后端拼好完整 URL 再返回 |
| 3 | 用 `/jobs` 返回的 `result.url` 直接当 src | 那个是相对路径，必须拼域名 + key |
| 4 | 同步功能也去轮询 | 看 `status === 'done'` 就直接渲染 |
| 5 | 异步功能等 5 分钟就超时 | 最多等 10 分钟，每 5 秒一次 |
| 6 | 带 `require` 的功能只调一次 | 先调「写提示词」拿 id → 再把它作为 `prompt_job` 传进去 |
| 7 | 失败也扣钱 | 上游失败**不扣点**，前端要写明 |
| 8 | 价格写死在代码里 | 从 `/caps` 读 `points` |

---

## 十四、自检脚本（跑完贴给 AI 看）

```bash
# 1) 后端起来了没
curl -s http://localhost:3000/api/caps | head -c 300

# 2) 余额
curl -s http://localhost:3000/api/balance

# 3) 同步功能（写剧本）
curl -s -X POST http://localhost:3000/api/create \
  -H 'Content-Type: application/json' \
  -d '{"kind":"script","text":"一个外卖骑手在暴雨里接单"}' | head -c 300

# 4) 异步功能（文生图）
curl -s -X POST http://localhost:3000/api/create \
  -H 'Content-Type: application/json' \
  -d '{"kind":"t2i","text":"一只橘猫坐在窗台"}' | head -c 300

# 5) 查任务（把上一步的 id 粘进来）
curl -s http://localhost:3000/api/job/<id> | head -c 400
```

---

## 十五、给 AI 的最终指令（直接粘这一句）

**给 IDE 内置 Agent / 命令行 Agent 这类能写文件的工具：**

> 按上面这份文档，用 **Node.js + Express + 原生 HTML**（第十一节那套完整代码），
> 给我生成一个完整可运行的网站目录结构，满足第九节的全部验收标准。
> API Key 从 `.env` 读，绝不进前端；产物 URL 在后端拼 `?key=`。
> 目录里要包含 `package.json`、`.env`（值留空我自己填）、`server.js`、`public/index.html`、`public/app.js`。
> 代码要能 `npm i && npm start` 直接跑起来，不要省略任何文件。

**给对话型 AI / 对话型 AI / 对话型 AI 这类对话工具（它们爱省略代码，必须加这句）：**

> 按上面这份文档，用 **Node.js + Express + 原生 HTML** 给我生成完整网站。
>
> 要求：
> 1. **每个文件的完整内容都要输出，不许用「...」「省略」「同上」代替**
> 2. 分文件输出，每个文件开头写清楚文件路径（如 `=== server.js ===`）
> 3. 一共 5 个文件：`package.json`、`.env`、`server.js`、`public/index.html`、`public/app.js`
> 4. API Key 从 `.env` 读，绝不进前端；产物 URL 在后端拼 `?key=`
> 5. 如果内容太长要分多条消息发，请说「继续」，我会回「继续」
>
> 现在开始，先发 `package.json` 和 `.env`。

> **为什么对话型 AI 要多这几句**：它默认会给你"示例片段"而不是完整文件，
> 尤其中间那几个大文件（server.js 200 多行、app.js 300 多行）大概率被它压缩成骨架。
> 一句「不许省略」能把完整度提上来一大截。

---

## 十六、各种 AI 工具怎么喂（按能力分类）

**不用管是哪个产品**，只看它能不能读写项目文件、能不能执行命令，分四类。

---

### A 类：能读写文件 / 能执行命令（最顺）

典型形态：编辑器里的 Agent 模式、命令行里的编程 Agent、IDE 里的「自主编码」功能。

判断标准：

| 能力 | 需要 |
|---|---|
| 能读你项目目录里的文件 | ✅ 必须 |
| 能新建 / 修改文件 | ✅ 必须 |
| 能自己跑 `npm install` / `npm start` | 加分 |

**操作三步：**

1. 新建一个空目录，把本文件 `api-agent-guide.md` 放进去
2. 在这个目录里启动那个 AI
3. 发这句：

> 读当前目录下的 `api-agent-guide.md`，按它的第十一节生成完整项目：
> `package.json`、`.env`、`server.js`、`public/index.html`、`public/app.js`。
> 每个文件都要写全，不要省略。`.env` 里的 `API_KEY` 留空，我自己填。

---

### B 类：只在编辑器里补全 / 对话（不能建项目）

典型形态：编辑器里的代码补全插件、侧边栏对话。

这类**不会自动建整个项目**，只能给你往当前文件里填内容。

**操作五轮：**

1. 手动建好目录和 5 个空文件
2. 把本文件作为上下文（附件上传，或粘进对话）
3. 逐个说：「把 `server.js` 的完整内容写出来」
4. 粘进对应文件
5. 重复 5 次

---

### C 类：纯对话（网页 / App / 聊天窗口）

只有聊天框，碰不到你的文件系统。**这类最爱省略代码。**

**第一步：喂文档**

支持附件的直接上传本文件；不支持的复制全文粘贴。

**第二步：发这段指令**

> 按上面这份文档，用 **Node.js + Express + 原生 HTML** 给我生成完整网站。
>
> 硬性要求：
> 1. **每个文件的完整内容都要输出，不许用「...」「省略」「同上」代替**
> 2. 分文件输出，每个文件开头写清楚路径（如 `=== server.js ===`）
> 3. 一共 5 个文件：`package.json`、`.env`、`server.js`、`public/index.html`、`public/app.js`
> 4. API Key 从 `.env` 读，绝不进前端；产物 URL 在后端拼 `?key=`
> 5. 内容太长就分多条发，我说「继续」你就接着发下一个文件
>
> 现在开始，**只发 `package.json` 和 `.env`**，发完停下等我。

**第三步：一轮一轮要**

```
你：只发 package.json 和 .env
AI：（输出）
你：继续，发 server.js
AI：（输出）
你：继续，发 public/index.html
AI：（输出）
你：继续，发 public/app.js
AI：（输出）
```

**为什么必须分轮次**：一次性要 5 个文件，对话型 AI 有 90% 概率把 `server.js`（200 行）和 `app.js`（300 行）压成骨架（几个函数名 + `// 省略`）。分轮次能降到 10% 以下。

---

### D 类：工作流 / 自动化编排平台

不能生成代码项目，适合把 API 当**插件/工具**接进去：

| 场景 | 做法 |
|---|---|
| 做成 Bot 给终端用户用 | 用户输入 → HTTP 请求节点调 `/v1/jobs` → 返回结果 |
| 批量 / 定时生成 | 定时触发 → 循环调 API → 存结果 |

这类平台**不需要本文件的大部分内容**，只参考「四、接口清单」那一节即可。

---

### 一句话判断

| 你的 AI 能做什么 | 属于 | 怎么用 |
|---|---|---|
| 读文件 + 写文件 + 跑命令 | A 类 | 放 md 进目录，一句话生成 |
| 只能在编辑器里补全 / 对话 | B 类 | 逐文件要，手动粘 |
| 只有聊天框 | C 类 | 分轮次要，强调「不许省略」 |
| 是编排平台（Bot / 工作流） | D 类 | 只当 API 插件用，别让它写代码 |

---

**所有类别共同的一条**：生成完先跑第十四节那 5 条 `curl`，后端通了再接前端。
