你正在看 ShipKit 的实时演示 —— 一个数据为预置种子的真实部署。到 shipkit.sh 获取模板

AI 图像与视频生成

/generate 页面、core/ai 里的服务商适配层、模型目录和价格、一次生成如何冻结积分、完成和结算,以及怎样加模型或加服务商。

ShipKit 自带一个能直接用的 AI 产品:登录用户在 /generate 输入提示词、选一个模型,拿回图片或视频,用积分付费。模型跑在 Replicate 上。提供哪些模型、各卖多少钱,都在 后台 → AI 里设置,不用重新部署。如果你的产品不卖模型调用,可以把整个模块删掉(见本页末尾)。

开启

生成只需要一样东西:Replicate 的 API token。两种方式任选:

  • 在 后台 → AI → 模型 的服务商密钥里粘贴(和所有 secret 类型的运营设置一样加密存储),或者
  • 本地在 .dev.vars 里设置 REPLICATE_API_TOKEN,线上用 wrangler secret put REPLICATE_API_TOKEN。

后台粘贴的优先。两者都没有时,这个服务商就是关闭的:它的模型不会出现,/generate 会提示暂无可用模型。/ai-models skill 会带你配好 token、预置模型和它们的价格。

用户看到什么

/generate 是一个提示词输入框,带 图片 和 视频 两个标签页、一个模型下拉框和一个参数面板。参数面板根据模型自己的输入 schema 生成,所以每个模型显示的正好是它接受的选项:画幅、质量、时长、种子等等。接受参考图的模型会多出一个上传区,文件可以拖入、粘贴或选择,并在提示词里用 @ 引用。

按钮上显示的是按当前参数算出的积分价格。输入框下面是用户的历史记录,最新的在前,可以预览、下载和删除。进行中的任务显示为占位块,完成后自动填上;只要还有未完成的任务,页面每 2.5 秒轮询一次。

生成结果存在你自己的 R2 桶里,只通过 /api/generations/$id/$index 流式返回给本人。删除一条记录会同时删掉它的文件。

一次生成的过程

所有生成都是异步的。一张图要几秒,一段视频要几分钟,Worker 的请求哪个都等不起。一次生成的完整生命周期都在 src/features/generations/server/lifecycle.ts 里,它也是唯一会修改状态的文件:

状态含义
queued行已写入、积分已冻结,服务商还没收到
running服务商已接受
saving服务商已完成,正在把结果复制进 R2
succeeded完成并已扣费
failed结束,不收费

开始。 服务端按模型的字段校验输入(表单没提供的字段一律拒绝),算出价格,冻结这么多积分,写入 generation 行,把输入图片上传给服务商,然后提交。余额不够这次报价时什么也不写,提示用户充值。

完成。 有三条路径能完成一次生成,其中任意两条可能同时到达:

  1. 服务商的 webhook POST /api/webhooks/generations/$provider,只有 APP_URL 是 https:// 时才会注册;
  2. 页面轮询它正在等的任务。本地开发只有这一条路,因为 Replicate 访问不到 localhost;
  3. 每日定时任务里的 generations.sweep,处理没人在看、webhook 也一直没来的任务。

先到的那条用一条带条件的 UPDATE 把行从 running 改成 saving。正是这个认领保证只有一方去复制结果和结算,其他到达者认领不到就直接返回。D1 没有事务,所以每一步都是一条带守卫的语句,顺序安排得让任意两步之间崩溃都只会留下一行 sweep 能接着处理的记录。

结果是复制,不是链接。 服务商的 URL 会过期(Replicate 一小时内),所以完成时会把每个文件流式写进 R2 的 generations/<userId>/<runId>/ 下。复制失败时任务退回 running,下一次轮询、webhook 重试或 sweep 会再试。

失败不收费。 失败,或者"成功但没有任何输出"(安全过滤通常就是这样),都会释放冻结的积分。

价格怎么算

模型的价格按服务商公布的方式写:每个输出多少美元,视频则是每秒多少美元(per: 'duration'),还可以按会影响价格的输入分档。

pricing: {
  usd: 0.09,
  tiers: [
    { when: { size: '1K' }, usd: 0.045 },
    { when: { size: '2K' }, usd: 0.09 },
  ],
},

积分由一个运营设置决定:加价倍数(generations.markup,默认 2,在后台 → AI 里改),基准是 src/config/plans.ts 里的 1 美元 = 100 积分。一张 0.09 美元的图在默认倍数下是 18 积分。单个模型也可以有自己的倍数,和其他模型分开定价。

价格是整数积分。单价(每张、每秒)在乘上倍数后向上取整,其余都是单价乘整数个数,所以标签、冻结额和实扣额是同一组整数。src/features/generations/pricing.ts 里的纯函数 quoteMicro 同时为按钮和服务端的冻结计算价格,输入也完全一样(表单默认值、用户填写的值和锁定值)。显示的价格就是扣的价格。

一次能返回多张图的模型,冻结的是最多张数的价格,实际按返回的张数扣费。要 4 张只回来 3 张,就扣 3 个单价。

上线前核对价格。 服务商会调价,应用本身察觉不到。bun run ai-models audit 把每个预置模型和 Replicate 当前公布的价格对比,有任何差异就以非零状态退出;加上 --db local 或 --db remote 还会检查后台 → AI 里保存的模型。

模型目录

实际生效的目录分两层,通过 src/features/generations/server/catalog.ts 读取,不要直接读配置文件:

  • 预置模型,在 src/config/models.ts 里:模板自带的模型(Seedream 5.0 Pro 和 GPT Image 2.5 的两个版本)。
  • ai_model 表里的行,由后台 → AI 写入。id 和预置模型相同的行覆盖它,其他 id 是运营人员自己加的模型。

和运营设置一样,没动过的预置模型没有对应的行,所以你在代码里改了预置模型,所有没编辑过它的部署都会收到;重置 就是删掉这一行。改动一分钟内在所有地方生效。

一个预置模型,带上所有可选字段:

{
  id: 'gpt-image-2.5-flare',      // 每次生成都会记录;在用的 id 不要改名
  provider: 'replicate',
  ref: 'openai/gpt-image-2.5-flare', // Replicate 上的 owner/name
  category: 'text-to-image',      // src/core/ai/categories.ts
  label: 'GPT Image 2.5 Flare',
  pricing: { usd: 0.25, tiers: [/* 按质量分档 */] },
  markup: 3,                      // 只作用于这个模型;不写则跟随全局倍数
  count: { field: 'number_of_images', max: 4 },
  hide: ['openai_api_key', 'user_id'], // 不显示,使用服务商默认值
  lock: { moderation: 'auto' },        // 固定为这个值,不显示
}

模型表单的其余部分都在运行时从服务商读取:输入项来自模型的 schema,封面和简介来自它的页面信息(直接引用原图,缓存一小时)。hide 和 lock 用来处理付费用户不该碰的输入。任何能让调用者自带密钥、或者在你的账号上放宽审核的输入,都应该隐藏或锁定。

在后台添加模型

后台 → AI → 模型(查看需要 generations.read,修改需要 generations.write)列出整个目录。添加模型 可以浏览 Replicate 某个分类的精选列表,也可以搜索它托管的全部模型。选中之后要设置:

  • 每个输入是显示、隐藏,还是锁定为某个值;
  • 价格。从 Replicate 读取 会根据模型公布的价格填好并对应到输入上;请核对一遍,页面上没写明的价格读不出来;
  • 可选的单独倍数,以及控制输出张数的输入。

编辑、启停和重置都会写入审计日志(generations.model_saved、generations.model_enabled 等)。

模型能接受什么输入

模型按 src/core/ai/categories.ts 分组,它参照 Replicate 的任务分类,只保留图片和视频相关的:文生图、图片编辑、放大、文生视频、图生视频等等。支持文字和图片输入。需要视频或音频输入的分类会列出来,但暂时不能往里加模型;schema 里必须提供这类输入的模型,在任何分类下都会被拒绝。

输入图片先存进 R2(POST /api/generations/inputs,支持 PNG、JPEG、WebP、GIF,单张最大 10 MB,放在用户自己的目录里)。提交任务时只带上传 id。提交那一刻,服务端把每个文件复制到服务商自己的文件存储,因为服务商访问不到私有桶或开发机,也不该拿到指向它们的公开链接。

添加服务商

src/core/ai/ 是一层与服务商无关的适配层,结构和 core/payment/ 一样,上层代码不知道自己在和哪个服务商打交道。一个服务商实现 src/core/ai/types.ts 里的 AiProvider:

方法作用
describe一个模型的页面信息,以及转换成表单字段的输入
browse某个分类的精选列表,或者搜索结果
pricing公布的价格(如果能读到)
stageInput上传输入文件,返回服务商能接受的 URL
submit发起一次生成,有 webhook 地址时一并带上
get查询任务当前状态,供轮询使用
parseWebhook校验签名并返回任务,伪造的返回 null

新增一个服务商就是:replicate.ts 旁边加一个文件,providers.ts 里加一行,在 AiProviderId 和 AI_PROVIDERS(src/features/generations/config.ts)里加上它的 id,再在 src/core/ai/settings.ts 和 src/core/env.ts 里加上它的 token。

通过 meter 扣费

generations 模块从不导入 credits 模块。它通过 src/core/meter/events.ts(holdUsage、settleUsage、releaseUsage)扣费,由 credits 模块在背后注册账本。协议就是积分里讲的先冻结、后结算;每笔扣费的账本 reason 是 generation,ref 是 generation:<runId>。

你自己的按量计费功能也用这三个调用。删掉积分模块后没有注册任何 meter,所有冻结都直接成功,生成就变成免费的。

后台与账号数据

  • 后台 → AI(/admin/models)放着服务商密钥、加价倍数和模型目录,是后台的一个分区,见管理后台。
  • 用户的生成记录随账号级联删除。他在 R2 里的文件,包括生成结果和上传的输入图,由账号删除钩子清理;数据导出里包含每次生成的模型、输入、状态和花费。

不需要这个模块

运行 /delete-feature generations。它会删掉这个模块、/generate、/admin/models、/api/generations 下的路由和 webhook,以及 src/config/models.ts。src/core/ai/ 和服务商 token 设置会保留,留给下一个调用模型的功能;token 会回到后台 → 设置里。见删除功能。