一个把 LLM 选题 → 剧本 → 主体确认 → 分格生图 → 人工审核 → 微信公众号草稿箱 全自动串起来的 Web 系统。开源、MIT、clone 三条命令跑起来。
- 仓库:
https://github.com/bigWhiteWhite/we-chat-official-account - 技术栈:Next.js 15 (App Router) + React 19 + TS strict + Prisma 7 (
@prisma/adapter-libsql) + TanStack Query/Table/Form + Zustand + shadcn/ui + Tailwind v4 + Vitest + oxlint/oxfmt - 规模:249 个 ts/tsx/prisma 文件,约 3.4 万行,201 个单测全绿,
tsc --noEmit零错误 - 不装数据库、不装 Docker,SQLite 是嵌入式的
一、整体结构:pipeline 纯函数 + route 薄壳
这是我在这个项目里最想坚持的一条约束,写进了 AGENTS.md:
src/
app/api/ Next.js 薄壳:参数校验 → SSE 建连 → 调 pipeline → JSON/SSE
✗ 不允许有业务 if-else
pipeline/ 核心业务层,状态机纯函数
✗ 不 import Next.js / React / db(可单独在 Vitest 里跑)
prompts/ 所有 LLM 提示词:纯文本 + 纯组装函数
✗ 不 import DB / model-manager / env
model-manager/ 模型注入层:端点解析 + 域主模型解析 + 失败分类
lib/ db 单例 / sse / 并发锁 / 设置读写
prisma/ Prisma 7 schema
tests/ Vitest(node 环境)
三条禁令换来三件好事:
- 测试不需要 mock Next.js。200 个测试直接
import { buildPanelRefs } from "@/pipeline/subjects"就完事。 - 提示词层可以被前端复用。比如用户在弹窗里手改单格提示词时,前端可以直接调
buildPanelPrompt()生成初稿,不必把逻辑再实现一遍——因为那个文件里只有字符串和纯函数。 - 换 Web 框架的成本被限制在
app/api/目录里(虽然我没打算换)。
环境变量相关的取值(画风预设、旁白是否入画等)一律由调用方显式传参,不在提示词模块内读默认值。这条一开始没定,加了两个功能之后 prompt 文件就开始偷偷依赖 process.env 了,回来补的约束。
二、一致性:把"画得不像"拆成三个可解的问题
AI 漫画最大的痛点是跨格不一致。但"一致性"是个笼统词,我按漫画行业的三个维度拆开,各对应一类可复用资产(DB 里统一叫 Asset,type = character | scene | style):
| 维度 | 现象 | 机制 |
|---|---|---|
| 角色身份 | 同一个人换脸/换发型 | 角色参考图,生图时按格取该格出场角色的图 |
| 场景 | 同一间屋子布局漂移 | 场景参考图;主场景不再单独文生图,未绑定就不传 |
| 画风 | 前厚涂后线稿 | 画风样图,优先级:库内样图 > STYLE_PRESET 兜底 |
用户在第 3 步「主体确认」点确认后,参考图路径写进 task.referencePathsJson,成为后续生图的唯一数据源。每一格的参考集是这个纯函数算出来的:
// src/pipeline/subjects.ts
export function buildPanelRefs(
script: ComicScript,
refs: TaskReferences,
panelIndex: number,
): string[] {
const panel = script.panels[panelIndex];
if (!panel) return [];
const out: string[] = [];
if (refs.style?.image) out.push(refs.style.image); // 画风样图
out.push(...panelCharacterRefs(refs, panel.character)); // 该格出场角色
if (refs.scene.image) out.push(refs.scene.image); // 主场景
return out;
}
关键在于动态:只出场 A 的格子不会带上 B 的参考图,避免画面里凭空多个人。
主场景不再单独文生图这件事,是被实测打脸后改的:先生成一张"标准场景图"再让每格参考它,结果角色图 + 场景图 + 画风图三个参考一叠,模型开始互相打架,不如直接只传角色和画风。
三、分格图版本化:不改 schema 地留住"抽卡历史"
生图是抽卡,但重抽会覆盖上一张。需求很明确:所有版本都留在磁盘上,并且能一键切回。
实现上没有加表也没有加字段:
- 落盘文件名
data/images/<taskId>/panel-NN.vN.png,N 从 v1 起;历史遗留的无版本名panel-NN.png视为初始版本 (v0),不占 vN 序号。 imagePathsJson[index]只存"当前选中版本的路径",是个指针。- 版本号 = 扫描目录取最大 vN + 1,所以手动删文件、中途换机器都不会错乱(天然自愈)。
GET /api/tasks/:id/images/:index/versions列版本并标isActive;POST同路由切指针,遇到在途任务返回 409。
代价是磁盘会涨,收益是"三格前抽到的好东西"永远回得去。
四、SSE + 双层并发锁
剧本生成、主体匹配、分格生图三个长任务全走 SSE,前端用统一的 stream hook 消费;同时所有事件写 TaskEvent 表,所以刷新页面、断线重连之后日志时间线还在(详情页 2s 轮询兜底)。
"生成中"这类操作必须有锁,但锁粒度很讲究:
- 主流程(剧本、批量生图)以
taskId为 key,且用hasAnyRun(taskId)做任务级互斥——任意一格在途时,批量生图也拒绝。 - 单格重生成以
panelRunKey(taskId, index)为 key,所以九格可以并行重画,同一格不能点两次。 abortRun(taskId)一次中断主流程和全部在途单格。runningPanelIndexes(taskId)供详情页返回"哪几格在生成",前端据此给单个格子盖遮罩,而不是整页禁用。
这里有一个必须处理的写冲突:并行的单格要各自往同一个 imagePathsJson 字段里追加自己的路径。直接 update 一定互相覆盖。做法是统一走一个封装(savePanelImage),内部是一条进程内串行链,每次写入前重读最新值再改:
// src/pipeline/panels.ts(节选)
/** 进程内串行链:多格并行重生成都会回写 imagePathsJson,串行化读-改-写防止互相覆盖 */
let imageWriteChain: Promise<unknown> = Promise.resolve();
export function savePanelImage(taskId, index, imgPath, panelCount) {
const run = imageWriteChain.then(async () => { // 挂在上一条之后
const fresh = await db.task.findUnique({ where: { taskId } }); // 重新读最新值
const base = fresh.imagePathsJson ? JSON.parse(fresh.imagePathsJson) : [];
while (base.length < panelCount) base.push(null);
const imagePathsJson = replacePanelImage(JSON.stringify(base), index, imgPath);
await db.task.update({ where: { taskId }, data: { imagePathsJson, imageCnt, status } });
});
// 链尾吞掉异常,避免一格失败阻塞后续格的落库
imageWriteChain = run.then(() => undefined, () => undefined);
return run;
}
那个「链尾吞异常」的细节是必须的:不吞的话某一次写失败会让 Promise 链变成 rejected,之后所有格子的落库全部挂不上去,整批生图静默停止写库。
单进程内够用;真要横向扩展时这里必须换成 DB 乐观锁或行级更新,我在代码里留了注释说明这个前提。
生图并发用 p-limit,IMAGE_GEN_CONCURRENCY 默认 2(考虑端点限流),并支持断点续传——中断后重新点生成,只补缺失的格子。
五、模型接入层:四个业务域,接新厂商零代码
模型按用途切四域,各自端点 + 各自主模型,没有内置兜底模型池:
| 业务域 | 环境变量中缀 | 实际调用方 |
|---|---|---|
main | MODEL_MAIN_* | 剧本流式生成(writer.ts)、主体匹配、主体扩展、本集简介 |
mainLight | MODEL_MAIN_LIGHT_* | 选题(topic.ts)、角色外观补全(subject-bind.ts)、标签说明书批量生成(CLI) |
vision | MODEL_VISION_* | 图像理解——当前零调用方,设置页也不展示这一张卡,只保留配置契约 |
imageApi | MODEL_IMAGE_API_* | 分格生图、主体预览图 |
配置入口有两个,推荐用网页 /settings。 高频动作(换模型做对比、临时切端点、试新网关)不该要求开发者打开 .env 再重启服务,所以设置页把这套配置全部搬进界面:两个 Tab,「模型配置」和「业务设置」,保存即时生效。
「模型配置」Tab 下每个域一张卡(当前三张:主推理 / 轻量文本 / 生图;vision 没有调用方,界面上先不渲染,但 MODEL_VISION_* 与 AppSetting 的读写照旧支持),字段是同一套:
| 字段 | 说明 |
|---|---|
| API 类型 | chat(/chat/completions)/ responses,多数网关选 chat |
| Base URL | 填到 /v1 为止 |
| API Key | 密码框,保存后只回显掩码 ****abcd;留空表示沿用已存 Key |
| 主模型 | 裸 modelId(如 deepseek-chat),右侧按钮从端点 GET /models 拉模型列表填进 datalist;拉不到就手输 |
| 生图协议 | 仅生图卡有:openai / si-image(见踩坑 1) |
每张卡底部有**「测试连接」**:先 GET /models 再发一次最小对话请求,返回 连接正常(xxx ms) 或原样打出失败原因。建议先测后存,别把 401 的配置写进库。
「业务设置」Tab 的卡片叫「公众号与内容」,六项:公众号 AppID / 公众号 AppSecret(掩码回显、留空不改) / 默认作者(草稿署名) / 默认画风(兜底描述,默认 简约线条+低饱和色块+扁平卡通,资产库有画风样图时以样图优先) / 生图并发(1~10,默认 2) / 旁白写入画面 开关(决定分格提示词是否保留旁白文案,会实质改变成图风格)。下方还有一块只读信息展示 DB_FILE 等启动期绑定的项。
想走命令行也可以,.env 里一组三行,等价于上面那张卡:
MODEL_MAIN_BASE_URL=https://your-gateway/v1
MODEL_MAIN_API_KEY=sk-xxx
MODEL_MAIN_PRIMARY=glm-4.6 # 裸 modelId = 绑本域端点;跨域借用写 域/modelId
配置读取走一个统一的覆盖层,优先级:DB(/settings 页保存) > .env > 默认值:
// src/lib/settings-store.ts
export function envOrSetting(envName: string): string | undefined {
const key = ENV_TO_KEY.get(envName);
const v = key ? cache()?.get(key) : undefined;
if (v && v.trim() !== "") return v; // DB 覆盖
const env = process.env[envName];
return env && env.trim() !== "" ? env : undefined; // 回落 env
}
所以在线改完点保存就生效,不用重启 dev server;密钥字段只回显掩码。
缺配置时选择"报错"而不是"降级":某个域没配 Key 或能力不匹配,调用直接抛中文错误并点名要改哪个变量,不会静默换模型。看起来不够"智能",但省掉了"我的模型到底跑的是哪个"这类查不出来的问题。多候选降级 + 冷却机制的代码路径还留着(fallback.ts),只是当前每域一个主模型、无候选可切,所以那个开关实际是空转的——这一点我在 README 里也如实写了,没有把它吹成特性。
六、踩到的四个具体的坑
1. 生图协议不是通用的:TokenHub / 混元走私有接口
默认按 OpenAI Images 实现:文生图 POST {base}/images/generations,图生图 POST {base}/images/edits(multipart 多图)。但腾讯云 TokenHub / 混元这类端点根本没有这两个路由,它提供的是:
POST {base}/wand/si-image/generation
差异不止路径:
| OpenAI Images | si-image | |
|---|---|---|
| 请求 | form / response_format=b64_json | JSON {model, prompt, size} |
| 参考图 | multipart 上传文件 | data:image/png;base64,… 数组,上限 10 张 |
| 响应 | 直接给 base64 | 给一个 url,要再下载一次;尺寸在 data[0].size("WxH") |
所以加了域级开关 MODEL_IMAGE_API_IMAGE_PROTOCOL=openai|si-image,在 /settings 生图卡片上下拉切换,artist.ts 里两条传输通路都保留。参考图超过 10 张时截断并打日志——丢哪几张不重要,但没有这层截断请求会直接 400。
教训:接第三方协议时,不要假设"OpenAI 兼容"这四个字覆盖生图。对话接口兼容的网关,生图接口经常是自家方言。
2. 测试连接和真实调用取了不同的 Key,导致误报 401
现象很迷惑:设置页配好的主模型点"测试连接"报 401,但实际生成任务是能跑的。
根因是两个 code path 的取 key 逻辑不一致:真实调用走 getGatewayHttpConfig(DB 覆盖 → env 回落),而"测试连接"只读表单字段,Key 框留空时它拿占位串 Bearer env 去请求真端点——网关返回 Invalid token 完全合理。
修复的关键不是"留空就读库里的 key"这么简单,而是安全边界:只有当表单里的 Base URL 与该域已保存的端点一致时才复用库内密钥,否则等于把已存 Key 发给用户手输的陌生地址。
function resolveProbeKey(biz, baseUrl, apiKey) {
const typed = apiKey?.trim();
if (typed) return { key: typed, reusedSaved: false };
const cfg = biz ? getGatewayHttpConfig(biz) : null;
if (cfg && cfg.apiKey !== "env" && normBase(cfg.baseUrl) === normBase(baseUrl))
return { key: cfg.apiKey, reusedSaved: true }; // 同端点才复用
return { key: "env", reusedSaved: false };
}
都不满足时退回占位 key,但在 401 的错误文案里明确写出"Key 留空且 Base URL 与已存配置不符,按占位 key 探测",不让用户误以为密钥错。
教训:任何"探测/试跑"类功能,必须和真实调用共享同一套配置解析函数。复制一份取配置的逻辑,就是埋一个只在生产环境才会炸的 bug。
3. Windows 上的 CRLF 让格式检查全线报错
oxfmt --check 在我机器上全量报格式错,实际代码没问题——Windows core.autocrlf=true 检出的是 CRLF 副本。解法是在 .gitattributes 里固定 * text=auto eol=lf,仓库里统一 LF,贡献者在 CONTRIBUTING 里提示 git config core.autocrlf input。
4. 微信 40164:出口 IP 白名单,以及如何把它做成一条能自愈的报错
第一次点发布,几乎必然吃一个 errcode 40164——你的公网出口 IP 不在公众号后台的 IP 白名单里,微信直接拒绝发 access_token。
这个错误本身没法"修"(配置项在微信侧),但报错信息可以被做得很有用。微信的 errmsg 长这样:
invalid ip 1.2.3.4, not in whitelist rid: …
所以我们做了三件事:
- 把出口 IP 从 errmsg 里抠出来,直接写进中文错误,用户不用再去
curl ifconfig.me:
// src/pipeline/weixin/token.ts
export function extractInvalidIp(errmsg?: string): string | null {
const m = errmsg?.match(/\b(\d{1,3}(?:\.\d{1,3}){3})\b/);
return m ? m[1] : null;
}
export async function failWithInvalidIp(errmsg?: string): Promise<never> {
await invalidateAccessToken(); // 先作废缓存 token
const ip = extractInvalidIp(errmsg);
throw new WeixinInvalidIpError(
`微信 40164 invalid ip${ip ? `(当前出口 IP:${ip})` : ""}:请将该 IP 加入公众号 IP 白名单后重试`,
);
}
- 40164 自愈包装。 缓存 token 是按 AppID 存的,出口 IP 一变(家宽重连、切了网络、换了代理)旧 token 就废了,但代码如果继续复用缓存就会一直 40164。所以所有微信调用走
withTokenRetry:命中 40164 就invalidateAccessToken()→ 重取 token → 自动重试一次,仍失败才抛上面那个带 IP 的错误。侧边栏另放一个手动刷新按钮(POST /api/weixin/token/refresh)作为兜底入口。 - 文案说"重试",文档说"再等几分钟"。白名单在微信侧有传播延迟,刚保存就重试大概率还是 40164,等两三分钟再点就通了——这个"要耐心"的前提光靠一行错误码说不清,所以 README 里单独开了一小节《第一次发布报
40164 invalid ip》,把"复制 IP → 去哪个页面 → 等 1~3 分钟 → 点侧边栏刷新 token → 不需要重启"写成编号步骤,免得用户以为白名单填错了反复改。反过来说,只有当你顺手改了.env里的APPID/APPSECRET才需要重启服务(那两个是启动期读的),改完.env不重启会看到"明明加了白名单还是 40164"的诡异现象。
教训:无法在代码里解决的外部配置错误,也要把「下一步该复制什么、去哪个页面、要不要等几分钟」写进错误信息。报错的有用程度,取决于你替用户省掉了多少次搜索。
七、上手与质量门禁
新库建完表后建议多跑一条:
pnpm db:seed # 灌入 22 个预设内容标签(含调好的 LLM 说明书)
标签不是分类字段,它的 description 会被原样拼进选题和剧本提示词,等于给 AI 一份题材说明书(写什么场景、靠什么情绪打动读者、角色怎么配、什么不要写)。空库跑一次就白得 22 条现成的,比让用户从空白页开始起名友好得多。
预设数据是纯数据模块(src/lib/preset-tags.ts),灌库脚本只做差集插入:
/** 纯函数:给定库里已有的标签名,算出还缺哪些预设(同名不覆盖,含前后空格归一) */
export function missingPresetTags(existingNames: Iterable<string>): PresetTag[] {
const seen = new Set<string>();
for (const name of existingNames) seen.add(name.trim());
return PRESET_TAGS.filter((tag) => !seen.has(tag.name));
}
因此脚本天然幂等:只补缺失、同名跳过,用户改过的说明书不会被覆盖;范围也严格限定在 Tag 表——不生成图片、不建合集/主体/任务,也不碰 AppSetting(模型端点和密钥归用户自己)。要灌别的库就 DB_FILE=./data/other.db pnpm db:seed,同时在 prisma.config.ts 里注册了 migrations.seed,prisma db seed 走同一个脚本。
提 PR 前的本地自检:
pnpm exec tsc --noEmit # 0 错误
pnpm lint # oxlint,0 error
pnpm format:check # oxfmt
pnpm test # vitest:201 passed(30 个文件)
pnpm build # next build
pnpm check:image # 可选:探测当前图像模型是否真的可用
涉及数据库的测试用临时 SQLite(tests/helpers/test-db.ts 的 setupTempDb()),不碰你的 data/comics.db。
八、很明确地不做什么
- 不做自动发表:自动化终点是草稿箱。模型产出未经审查,最后一道人工判断不该省。
- 不做多用户 / 鉴权:全站没有任何登录,
/api/*直接读写数据库和设置项。它是本机单用户工具,别部署公网、别反代出去。 README 里用加粗写着。 - 不内置厂商适配表:宁可用一个协议开关,也不维护一份会腐烂的"厂商怪癖表"。
- 不做内容审查:发布前请自行确认合规,也留意真实计费额度。
密钥有两处:.env 和 data/comics.db 里的 AppSetting 表(/settings 页保存的内容)。两者都进了 .gitignore,但分享截图或数据库前请自行清理。
九、路线图 / 欢迎来提 Issue
- 生图 prompt 的九维分格标准已经写进提示词,但分格构图(跨格、视线引导)还能更严
- 主体自动匹配目前靠 LLM 语义对齐,缺一个"这一格的角色和库里哪个最像"的视觉校验
- 想看你接的模型端点:不同网关的生图方言越收集越多,
si-image之外应该还有第三第四种
如果这个项目对你有启发,点个 star 就是最实在的支持。Issues 和 PR 都欢迎,尤其是"我这边端点是这个返回格式"这种一手反馈。
git clone https://github.com/bigWhiteWhite/we-chat-official-account.git
cd we-chat-official-account && cp .env.example .env
pnpm install && pnpm db:push
pnpm db:seed # 可选:灌入 22 个预设内容标签
pnpm dev # http://localhost:3000,模型配置也可以在 /settings 页里填
十、想看真实产出?这个号就是用它做的
开源之前我先用它运营了一个公众号:修仙小师妹,仙侠题材多格漫画。选题、剧本、人物设定、分格图全部走这条流水线,我只做两件事——确认主体、审稿,然后点发表。
微信搜「修仙小师妹」(文章末尾附二维码)。它更新节奏不快,因为人工审核那一步我故意保留;但你在上面能直接检验这篇文最该被检验的三件事:跨格角色一致不崩、连载前后衔接得住、以及画风稳不稳定。要不要跑自己的号,先看看成图质量再决定。