📑 目录
📖 标准文档🤖 接单版(V1)🏪 代理版(V2)🔒
⬇ .md
怎么用:点「一键复制全文」→ 粘给任意具备写代码能力的 AI 助手 → 它会按文档生成一个能跑的前后端。
或者点「下载 .md」,把文件直接丢进 AI 的对话或项目目录。

接单版 · 建站引导(给 AI 编程助手)

> 这是「接单版」:做一个你自己用的站——一个 API Key、一个站、一个人干活。

> 想把服务卖给客户(多用户各用各的额度),用「代理版」那一份。

>

> 怎么用:整页复制(Ctrl+A / Ctrl+C),粘给你的 AI 编程助手(任意具备写代码能力的 AI 助手)。

> 它会给你生成一个 npm i && npm start(或 pip install -r requirements.txt && python server.py)

> 就能跑起来的完整网站。不需要你再补任何背景。

一、你的任务

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

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

二、技术栈(两套选一套,别自创)

方案后端前端启动
A(推荐)Node.js + Express原生 HTML + JSnpm i && npm start
BPython + FastAPI原生 HTML + JSpip 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 丢了 / 要换一把怎么办

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

四、接口清单(自包含,不用另找文档)

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

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

4.1 能力清单(启动时调一次)

GET {BASE}/caps
Authorization: Bearer sk-live-xxx
{
  "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 结构(照抄)

kindinput同步说明
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 = 最多等这么多秒。

同步功能返回:

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

异步功能返回:

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

4.4 查询任务

GET {BASE}/jobs/{id}
{
  "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
3sync: true 的功能直接用返回的 result.text 渲染
4sync: false 的功能每 5 秒 轮询 /api/job/{id},最多 10 分钟
5轮询期间显示 queued/running 和已等待秒数
6文本 → <p>;图片 → <img>;视频 → <video controls>
7失败 → 显示 error 并标注「本次未扣点」
8每个任务结束刷新一次余额
9后端返回 402 → 提示「点数不足,请联系管理员充值」

不要做

六、你的后端要给前端提供这 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 是必填的。

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

{ "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 生成代码时最常踩的坑,参考实现里已经处理好了)。

后端统一转成:

{ "ok": false, "code": "insufficient_points", "message": "点数不足,需要 100 点" }
上游 HTTPcode前端提示
400invalid_input显示 message
401invalid_api_key「API Key 无效,请检查 .env」
402insufficient_points「点数不足,请联系管理员充值」
429rate_limited「请求太频繁,5 秒后重试」
501not_ready「该功能暂未开放」
5xxupstream_error「服务暂时不可用,请稍后再试」

八、安全红线(4 条,必须遵守)

九、验收标准(9 条,全满足才算完成)

十、目录结构

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

{
  "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

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

<!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

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

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 写进前端 JSKey 只在后端,前端调你自己的 /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 看)

# 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加分

操作三步:

> 读当前目录下的 api-agent-guide.md,按它的第十一节生成完整项目:

> package.json、.env、server.js、public/index.html、public/app.js。

> 每个文件都要写全,不要省略。.env 里的 API_KEY 留空,我自己填。

B 类:只在编辑器里补全 / 对话(不能建项目)

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

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

操作五轮:

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,后端通了再接前端。