把 AI 生图接入网站
# 把 AI 生图接入网站
你已经能在 ComfyUI 或模型平台生成图片,下一步是让用户在自己的网站里使用它:输入描述、点击生成、等待片刻,然后下载图片。
这篇解决三个具体问题:生成期间页面显示什么,刷新后怎样找回结果,失败时怎样避免重复生成和扣费。 以下用商品海报功能举例,接口名称是业务设计示例,不是某家模型平台的现成 API。
# 点击生成后,前后端分别做什么
假设用户上传一张杯子照片,要求生成咖啡馆风格的海报。图片需要几十秒才能完成,用户不应该一直对着没有反馈的按钮。
| 顺序 | 用户看到什么 | 系统做什么 |
|---|---|---|
| 1. 提交 | 按钮显示正在提交,防止连续点击 | 后端检查图片、登录状态和使用额度 |
| 2. 接受任务 | 显示排队中 | 后端保存任务,返回编号,例如 img_001 |
| 3. 开始生成 | 显示生成中 | 后台调用模型 API,或执行 ComfyUI 工作流 |
| 4. 生成完成 | 展示海报和下载按钮 | 保存结果文件,将任务标记为成功 |
| 5. 刷新页面 | 仍能看到同一个任务 | 根据任务编号重新查询,不再重新生成 |
后台执行程序通常叫 Worker。它可以在自己的 GPU 服务器上运行,也可以只负责提交和查询第三方平台任务,使用云端模型 API 并不要求自己有 GPU。
短耗时、低并发的功能可以先使用普通请求返回结果。生成较慢,或者需要刷新恢复、排队和取消时,再拆成任务,不必一开始就搭复杂消息系统。通用实现见 异步任务笔记。
# 用两个接口完成提交和查进度
# 提交:先得到任务编号
用户已上传素材并拿到 asset_cup_01,前端提交:
POST /api/image-tasks
Content-Type: application/json
Idempotency-Key: poster-request-001
{
"inputAssetId": "asset_cup_01",
"prompt": "保留杯子外观,背景改为温暖的咖啡馆,右侧留出文案位置"
}
2
3
4
5
6
7
8
inputAssetId 指向已上传图片,后端检查它属于当前用户。Idempotency-Key 是本次提交的唯一标识:网络出错后重发同一次请求,仍使用同一标识;用户主动重新生成,才使用新标识。
后端接受任务后返回 HTTP 202 Accepted 和 JSON:
{ "taskId": "img_001", "status": "queued" }
后端在同一个用户范围内保证幂等键唯一,相同键但参数不同应拒绝,不能覆盖旧任务。前端禁用按钮只能减少误点,不能替代这个后端保证。
# 查询:根据状态更新页面
前端每隔几秒调用 GET /api/image-tasks/img_001。生成过程中返回 queued 或 running,成功时返回:
{
"taskId": "img_001",
"status": "succeeded",
"imageUrl": "/api/image-assets/poster_001"
}
2
3
4
5
前端收到成功后停止查询,用 imageUrl 展示图片;失败、取消或离开页面时也停止查询。查询和下载接口都要检查当前用户是否有权限,不能只凭任务编号放行。
平台只报告排队或运行状态时,就显示对应文字,不伪造精确百分比。只有平台提供可信进度,才据此显示进度条。
# Worker 怎样真正调用 ComfyUI
业务接口不是直接把用户 JSON 转发给 ComfyUI。Worker 从服务端加载一份已经验收的工作流,只替换允许用户调整的字段,再向内网 ComfyUI 提交。这样用户不能自行指定未知节点、服务器路径或任意高成本参数。
| ComfyUI 接口 | 在本例中做什么 | 返回值怎么使用 |
|---|---|---|
POST /prompt | 提交 API 格式的工作流 | 保存返回的 prompt_id,它是执行任务标识,不是文字提示词 |
GET /history/{prompt_id} | 查询已完成或失败任务的记录 | 检查状态,再读取输出节点中的图片描述 |
GET /view | 按文件名、子目录和类型读取图片 | 获取图片字节,转存为业务资产 |
/ws | 接收执行、进度和预览事件 | 有实时体验需求时使用;事件要按任务标识区分 |
/history 尚无记录,不代表任务失败,也可能正在排队或执行。业务任务持久化不能依赖 ComfyUI 内存历史永久存在。
# 最小脚本:提交、等待、下载
先在本机 127.0.0.1:8188 跑通 基础 SD 工作流,保留 Save Image,通过 File → Export (API) 导出为 workflow-api.json。下面对应官方基础示例中的节点 6(正向文字)与 3(KSampler);如果自己的节点编号不同,需要按导出文件调整,不能直接猜。
保存为同目录下的 generate.mjs,用 Node.js 18+ 执行 node generate.mjs。这是单任务调用示例,不包含网站鉴权、数据库和计费模块。
import { readFile, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
const base = 'http://127.0.0.1:8188'; // 仅连接受控的本机 ComfyUI。
const clientId = randomUUID();
async function request(path, options = {}) {
const response = await fetch(base + path, {
...options,
signal: AbortSignal.timeout(30_000), // 单次网络请求超时,不是生成时限。
});
if (!response.ok) {
throw new Error(`ComfyUI HTTP ${response.status}: ${await response.text()}`);
}
return response;
}
// 已有编号时只查询原任务。生产环境应从数据库读取,不依赖终端输出。
let promptId = process.env.RESUME_PROMPT_ID;
if (!promptId) {
const workflow = JSON.parse(await readFile('workflow-api.json', 'utf8'));
if (workflow['6']?.class_type !== 'CLIPTextEncode' ||
workflow['3']?.class_type !== 'KSampler') {
throw new Error('工作流节点编号与本例不同,请先核对导出文件');
}
// 修改可信模板的明确字段,不接收用户上传的任意执行图。
workflow['6'].inputs.text = 'white ceramic cup, cafe, product photography';
workflow['3'].inputs.seed = 42;
const submitted = await (await request('/prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: workflow, client_id: clientId }),
})).json();
if (!submitted.prompt_id) throw new Error('未返回任务编号,不能视为提交成功');
promptId = submitted.prompt_id;
console.log('保存此 prompt_id,之后可恢复查询:', promptId);
}
// 示例最多等待 10 分钟;超时只停止本脚本等待,不自动取消或重复提交。
const deadline = Date.now() + 10 * 60_000;
let completed = false;
while (Date.now() < deadline) {
const history = await (await request(`/history/${encodeURIComponent(promptId)}`)).json();
const record = history[promptId];
if (record?.status?.status_str === 'error') {
throw new Error(`生成失败:${JSON.stringify(record.status.messages)}`);
}
if (record?.status?.completed) {
// Save Image 的图片描述包含 filename、subfolder 和 type。
const images = Object.values(record.outputs ?? {}).flatMap(node => node.images ?? []);
if (!images.length) throw new Error('任务完成但没有图片,请检查 Save Image 节点');
const output = images[0]; // 本例只下载第一张,批量任务应遍历并分别保存。
const query = new URLSearchParams({
filename: output.filename,
subfolder: output.subfolder ?? '',
type: output.type ?? 'output',
});
const response = await request(`/view?${query}`);
// 基础 Save Image 模板输出 PNG;固定本地文件名,避免直接信任远端路径。
await writeFile('result.png', Buffer.from(await response.arrayBuffer()));
console.log('已保存 result.png');
completed = true;
break;
}
await delay(2000);
}
if (!completed) throw new Error(`等待超时,保留 ${promptId},查询原任务而非重新提交`);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
中断后可以将已记录的编号放入 RESUME_PROMPT_ID 再运行,脚本会跳过提交。该文件路径和恢复方式只用于本地学习;业务 Worker 应持久化 taskId → prompt_id、输出资产位置和最终状态。
本例使用本地模型节点;如果工作流包含调用外部付费服务的 API 节点,还涉及该节点自己的鉴权、计费和数据上传,不能因为由本机 ComfyUI 发起就认为是离线免费执行。
# 生产接入最重要的三处差异
- 模板受控。 后端固定节点图,限制尺寸、批次、模型与输入文件;用户提供的是业务参数,不是任意执行代码。
- 先存编号,再跟踪。 保存业务任务与执行编号;查询失败不立即重复提交。业务幂等键不自动让下游
/prompt也具有相同幂等语义。 - 结果归业务系统。 下载成功后写入受控资产存储,保存模型与参数,再更新业务状态;不能把 ComfyUI 的
/view直接作为所有用户共享的公开下载口。
# 刷新、超时和取消怎么处理
# 刷新:找回原任务
把任务编号放在 URL 或用户任务历史中,例如 /posters/img_001。刷新只查询,不再次提交。任务记录在数据库中,浏览器关闭不会删除它。
# 超时:先确认有没有开始生成
超时只说明没有及时收到响应。平台可能已经开始生成,直接再提交一次可能生成两张并收两次费。
| 已知情况 | 处理方式 |
|---|---|
| 已拿到平台任务编号 | 查询原任务,不重新生成 |
| 平台支持幂等键 | 按它的约定使用同一键恢复原请求 |
| 平台明确返回参数错误 | 修改参数,不自动重试同一请求 |
| 没有编号,也无法确认是否受理 | 保留待确认状态,不无限重试 |
业务任务编号和模型平台编号不是一回事,后端需要保存对应关系,才能从网站里的 img_001 追到真实生成任务。排障时也要分开记录排队时间和模型执行时间,避免把排队太久误判成模型变慢。
# 取消:停止等待不等于停止计费
排队中的任务可标记取消,Worker 开始前再次检查。已提交到外部平台的任务,需要调用其取消接口并确认结果;不支持取消时,只能停止页面等待,不能承诺模型已停止或费用已退回。
# 怎样让这个功能真的好用
- 先预览,再生成大图。 用户确认背景和构图后再放大,减少高成本试错;同时限制单用户并发、每日预算和重试次数。
- 准确文字用程序排版。 商品价格、日期和二维码在背景生成后叠加,不把正确性完全交给模型。
- 费用按可用结果算。 假设每次 0.2 元,10 次只选中 2 张,每张可用图的模型费用就是 1 元,还未包含放大与修图。这是计算示例,不是实际报价。
- 结果按需要转存。 第三方下载链接可能过期,按服务条款保存到自己的资产系统,并提供有权限控制的下载入口。
保存结果时记录模型、工作流、参数、Seed 和输入素材版本,便于复现。缓存也要区分这些条件和用户权限,不能因 Prompt 相同就把甲的私有图片返回给乙。上传限制格式和大小,临时素材设置清理期限;模型许可与图片使用权分别确认。
# 面试问答
1. 怎么把 AI 生图做成网站功能?参考答案
我会把长耗时生成拆成提交任务和查询结果两部分。用户点击后先拿到任务编号,页面显示排队或生成中,后台调用模型并保存结果;刷新只查询原任务,不重新生成。后端用幂等键防重复提交,并保存业务任务编号与平台编号的对应关系,这样超时后才能继续追踪,避免重复计费。
2. 生成请求失败,直接重试不就可以了吗?参考答案
不能一律重试,因为超时不代表模型没执行,可能只是响应没回来。我会先用平台任务编号查状态,或者按平台的幂等机制恢复原请求。完全无法确认是否受理时,保留待确认状态,不盲目重复提交,否则可能生成两份、扣两次费用。
3. 为什么前端禁用按钮还不够?参考答案
禁用按钮只能防止当前页面连续点击,刷新、多标签页和网络重发仍可能重复提交。后端需要在用户范围内用唯一幂等键识别同一次请求,并返回原任务;相同键却带了不同参数时应拒绝。这样才能从业务上避免重复创建任务。