或者点「下载 .md」,把文件直接丢进 AI 的对话或项目目录。
接单版 · 建站引导(给 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 丢了 / 要换一把怎么办
- 打开网页 →「我的」→ 找到那把 key → 点「吊销」
- 点「创建 API Key」→ 复制新生成的(只显示一次)
- 改你项目
.env里的API_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 结构(照抄)
| 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 = 最多等这么多秒。
同步功能返回:
{ "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 |
| 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 是必填的。
# 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 点" }
| 上游 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 条,必须遵守)
- API Key 只在后端,不进前端、不进日志、不进 git
- 产物 URL 在后端拼
?key=,前端拿到的是完整地址 - 需求文本长度限制 8000 字符以内(后端校验)
- 你的
/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
{
"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) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 写进前端 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 看)
# 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放进去 - 在这个目录里启动那个 AI
- 发这句:
> 读当前目录下的 api-agent-guide.md,按它的第十一节生成完整项目:
> package.json、.env、server.js、public/index.html、public/app.js。
> 每个文件都要写全,不要省略。.env 里的 API_KEY 留空,我自己填。
B 类:只在编辑器里补全 / 对话(不能建项目)
典型形态:编辑器里的代码补全插件、侧边栏对话。
这类不会自动建整个项目,只能给你往当前文件里填内容。
操作五轮:
- 手动建好目录和 5 个空文件
- 把本文件作为上下文(附件上传,或粘进对话)
- 逐个说:「把
server.js的完整内容写出来」 - 粘进对应文件
- 重复 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,后端通了再接前端。