或者点「下载 .md」,把文件直接丢进 AI 的对话或项目目录。
给 AI 助手的执行命令(最高优先级)
> 本节为最高优先级指令。与下文冲突时以本节为准。
你的任务:依据本文件从零构建一个可运行的 Node.js 项目。本文件是唯一技术规范。
硬性规则
| # | 规则 | 说明 |
|---|---|---|
| 1 | 唯一规范 | 不查阅外部文档,不引入本文件未提及的库或框架。文档未覆盖的细节自行决定;文档已明确的必须原样执行。 |
| 2 | 完整输出 | 每个文件的代码必须完整。禁止出现 // ...、// 省略、同上、略、伪代码或片段。 |
| 3 | 按轮次输出 | 第十二节共 8 轮。每轮只输出该轮内容,输出完毕后停止,等待用户输入「继续」。 |
| 4 | 技术栈锁定 | Node.js + Express + better-sqlite3 + dotenv。禁止 React / Vue / TypeScript / 任何构建工具。 |
| 5 | Key 隔离 | API_KEY 仅存在于服务端进程(从 .env 读取)。禁止出现在 HTML、前端 JS、API 响应体中。 |
| 6 | 只做阶段一 | 完成本地可运行并通过自检后停止。禁止执行任何部署操作(服务器、域名、nginx、pm2、HTTPS)。 |
交付前自检
- 运行第十五节自检脚本
- 逐条核对 13.1 的 9 项本地验收
- 全部通过后输出运行方式,然后停止
开工前确认
需向用户确认以下三项。付款方式已固定为收款助手(见 8.5),无需确认。
| 项 | 取值 |
|---|---|
| 注册是否赠送试用点 | 0 点 / 指定点数 |
| 站点名称 | 显示于页面标题 |
| 客服联系方式 | 微信 / QQ / 邮箱,用于余额不足提示 |
> 这是「接单版」:做一个你自己用的站——一个 API Key、一个站、一个人干活。
> 想把服务卖给客户(多用户各用各的额度),用「代理版」那一份。
>
> 怎么用:整页复制(Ctrl+A / Ctrl+C),粘给你的 AI 编程助手(任意具备写代码能力的 AI 助手)。
> 它会给你生成一个 npm i && npm start(或 pip install -r requirements.txt && python server.py)
> 就能跑起来的完整网站。不需要你再补任何背景。
一、项目范围
构建一个单用户使用的 AI 内容生成网站。
功能
| 项 | 说明 |
|---|---|
| 输入 | 需求文本(可选上传参考图) |
| 处理 | 转发至平台 API,生成剧本 / 提示词 / 图片 / 视频 |
| 输出 | 任务进度、生成结果、播放与下载 |
| 账户 | 点数余额展示 |
架构
浏览器 ──→ 本站(Node)──→ 平台 API(Bearer: 你的 Key)
边界
本站不实现 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} | ❌ | H3视频短剧 |
关于「两步」类功能:/caps 里如果某个 kind 带 require 字段(比如 "require": "prompt_job"),
说明它必须分两步:先调对应的「写提示词」功能拿到任务 id,再把这个 id 作为 input.prompt_job 传进去。
少了会返回 400。具体是哪个 kind,看 caps 里哪个有 require —— 代码里不要写死。
注意字段名不一样:
| 步骤 | 文本字段 | 还要带 |
|---|---|---|
| 第一步(写提示词) | input.text | sec、resolution |
| 第二步(出片) | input.prompt | input.prompt_job(第一步拿到的 id)、sec、resolution |
第二步的 prompt 是照着拍的稿子,跟「客户的需求描述」不是一回事,所以字段名分开了。
完整参数(两步流程的功能)
| 字段 | 第一步(写提示词) | 第二步(出片) | 说明 |
|---|---|---|---|
| 文本 | input.text | input.prompt | 第一步是需求,第二步是稿子 |
prompt_job | — | ✅ 必填 | 第一步拿到的任务 id |
sec | ✅ | ✅ | 时长,2 ~ 30 秒(默认 5,超范围自动夹紧) |
resolution | ✅ | ✅ | 480P / 720P / 1080P(默认 480P) |
aspect | 可选 | 可选 | 画面比例 |
images | 可选 | 可选 | 参考图,传 asset id 或 id 数组(支持多张) |
video | 可选 | 可选 | 参考视频,传 单个 asset id |
参考图/参考视频要先上传:
POST /v1/assets { "data": "data:image/png;base64,...", "name": "主角.png" }
→ { "id": "ast_xxx" }
拿到的 id 传进 images(可多个)或 video(一个),平台会自己换成真实路径。
// 带参考图的第二步
{ "kind": "<出片的那个>",
"input": {
"prompt": "提示词稿",
"prompt_job": "xxx",
"sec": 10,
"resolution": "720P",
"images": ["ast_aaa", "ast_bbb"],
"video": "ast_ccc"
} }
> 参考图用来锁人物长相、参考视频用来锁运镜方式。都不传也能跑,但人物一致性会差一些。
关于 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.8 用量统计(可选)
想看自己最近用了多少:
\GET {BASE}/usage?days=30
→ { "items": [{"kind": "script", "jobs": 12, "points": 210}, ...] }
\
按品类汇总。界面上可以做一页「用量」给自己看,不做也不影响主要功能。
五、界面规格
┌──────────────────────────────────────────┐
│ 余额:4,800 点 [刷新] │
├──────────────────────────────────────────┤
│ 功能:[下拉框,从 /caps 动态生成] │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ (多行输入框,写需求) │ │
│ └──────────────────────────────────────┘ │
│ [📎 上传参考图] 已选:xxx.png │ ← 改图/反推才显示
│ [开始生成] │
├──────────────────────────────────────────┤
│ 任务列表 │
│ ┌──────────────────────────────────────┐ │
│ │ 写剧本 · 生成中… 已等 12 秒 │ │
│ ├──────────────────────────────────────┤ │
│ │ 文生图 · 完成 [查看] [下载] │ │
│ ├──────────────────────────────────────┤ │
│ │ 出片 · 失败:内容被拒 (未扣点) │ │
│ └──────────────────────────────────────┘ │
└──────────────────────────────────────────┘
必做行为(10 条)
| # | 行为 |
|---|---|
| 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 → 提示「点数不足,请联系管理员充值」 |
| 10 | 改图(edit)和图片反推(reverse)必须先上传图:选中这两个功能时显示「上传参考图」,上传后拿 asset id 传给 input.asset。没上传就点生成 → 提示"先上传图片" |
不要做
- ❌ 用户注册 / 登录(一个站点一个使用者,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 |
| 403 | account_blocked | 「账号被限制,请联系管理员」 |
| 401 | invalid_api_key | 「API Key 无效,请检查 .env」 |
| 402 | insufficient_points | 「点数不足,请联系管理员充值」 |
| 429 | rate_limited | 「请求太频繁,5 秒后重试」 |
| 501 | not_ready | 「该功能暂未开放」 |
| 5xx | (无 code,网关层直接返回) | 「服务暂时不可用,请稍后再试」 |
八、安全约束(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>
<div class="row" id="assetRow" style="margin-bottom:10px;display:none">
<label style="cursor:pointer;padding:7px 14px;border:1px solid #d1d5db;border-radius:9px;
background:#f9fafb;font-size:13px">
📎 上传参考图
<input type="file" id="imgFile" accept="image/*" style="display:none">
</label>
<span class="muted" id="assetTip">改图 / 反推需要先选一张图</span>
</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;
let ASSET = ''; // 上传后拿到的 asset id
const TASKS = new Map();
// 上传参考图(改图 / 反推用)
document.addEventListener('change', async function (ev) {
if (ev.target.id !== 'imgFile') return;
const f = ev.target.files && ev.target.files[0];
if (!f) return;
const tip = $('#assetTip');
tip.textContent = '上传中…';
try {
const b64 = await new Promise(function (res, rej) {
const r = new FileReader();
r.onload = function () { res(r.result); };
r.onerror = rej;
r.readAsDataURL(f);
});
const d = await api('/api/upload', { method: 'POST', body: { data: b64, name: f.name } });
ASSET = d.id || '';
tip.textContent = '已上传:' + f.name;
} catch (e) {
ASSET = '';
tip.textContent = '上传失败:' + e.message;
}
}); // 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: 'H3视频短剧', }[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';
// 改图 / 反推要图
const _needImg = ['edit', 'reverse'].indexOf(k) >= 0;
const _ar = $('#assetRow');
if (_ar) _ar.style.display = _needImg ? '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 (['edit', 'reverse'].indexOf(kind) >= 0) {
if (!ASSET) return alert('先上传一张参考图');
body.asset = ASSET;
}
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)
十一、常见实现错误(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 |
十二、自检脚本
# 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
十三、执行指令
给 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 直接跑起来,不要省略任何文件。
给 Codex / WorkBuddy / 豆包工作版(它们爱省略代码,必须加这句):
> 按上面这份文档,用 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。
> 为什么这几个工具要多叮嘱几句:它们默认给的是"示例片段"而不是完整文件,
> 尤其中间那几个大文件(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 个文件时,对话型模型通常会将 server.js(200 行)与 app.js(300 行)输出为骨架(仅函数签名 + // 省略)。分轮次能降到 10% 以下。
D 类:工作流 / 自动化编排平台
不能生成代码项目,适合把 API 当插件/工具接进去:
| 场景 | 做法 |
|---|---|
| 做成 Bot 给终端用户用 | 用户输入 → HTTP 请求节点调 /v1/jobs → 返回结果 |
| 批量 / 定时生成 | 定时触发 → 循环调 API → 存结果 |
这类平台不需要本文件的大部分内容,只参考「四、接口清单」那一节即可。
一句话判断
| 你的 AI 能做什么 | 属于 | 怎么用 |
|---|---|---|
| 读文件 + 写文件 + 跑命令 | A 类 | 放 md 进目录,一句话生成 |
| 只能在编辑器里补全 / 对话 | B 类 | 逐文件要,手动粘 |
| 只有聊天框 | C 类 | 分轮次要,强调「不许省略」 |
| 是编排平台(Bot / 工作流) | D 类 | 只当 API 插件用,别让它写代码 |
所有类别共同的一条:生成完先跑第十四节那 5 条 curl,后端通了再接前端。