// 生成链的进度广播:把"服务端正在干什么"实时说给等待的人听。 // // 为什么需要它:实测生成一次 P50 约 40 秒、P95 超过 110 秒(思考模型 + 最多三轮闸门 // 回喂 + 空输出重试),而这段时间里页面上只有一行静止的"生成中"。用户看不到进展, // 就只能在"是不是卡死了"和"再等等"之间反复猜——**等待本身不可怕,不知道在等什么才可怕**。 // // 生成链内部本来就是分段的(选型 → 生成 → 闸门 → 冒烟 → 回喂重生成),这些事实 // 现在全烂在服务端日志里。这里把它们收成一份可订阅的快照。 // // 三条设计取舍: // // 1. **进度是可丢的**。任何一次进度写入失败、订阅者不存在、任务号对不上,都不能影响 // 生成本身——它是旁路,不是主链路。所有写入都吞异常。 // 2. **任务号由客户端生成**。客户端要在 POST 发出**之前**就知道去哪儿查进度; // 等服务端返回再给号,那时候生成早就跑完了。代价是要防"任意字符串塞爆内存": // 格式校验 + 数量上限 + TTL 三道。 // 3. **只存快照,不存事件流**。订阅端要的是"现在到哪一步了",不是完整历史; // 存快照让内存占用与任务数成正比而不是与耗时成正比,断线重连也不用补历史。 /** 阶段口径与生成链的实际分段一一对应,不另造语汇 */ export type GenerationStage = | 'queued' // 排队等信号量(并发满时会停在这儿) | 'selecting' // 分层检索第一段:让模型挑组件 | 'generating' // 打模型要产物 | 'validating' // 静态闸门校验 | 'smoking' // 渲染冒烟(零 Domain 产物才做) | 'compiling' // 蓝图编译 | 'done' | 'failed'; export interface GenerationProgressSnapshot { taskId: string; stage: GenerationStage; /** 第几次模型调用(含空输出重试)——用户看到"第 2 次尝试"比看到进度条百分比更有信息量 */ attempt: number; maxAttempts: number; startedAt: number; updatedAt: number; /** 人话说明:"闸门指出 3 个问题,正在带着问题重生成" */ note: string; done: boolean; /** done 为真时才有意义 */ ok?: boolean; } /** 任务号格式:客户端生成,只收 8~64 位的 [A-Za-z0-9_-](UUID / nanoid 都满足) */ const TASK_ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/; /** 同时在册的任务上限:超了从最旧的开始丢(正常并发远小于这个数) */ const MAX_TASKS = 256; /** 快照存活时长:生成最长也就两三分钟,10 分钟足够覆盖"跑完后再来看一眼" */ const TTL_MS = 10 * 60 * 1000; const snapshots = new Map(); export function isValidTaskId(raw: unknown): raw is string { return typeof raw === 'string' && TASK_ID_PATTERN.test(raw); } /** * 开一个进度记录并返回写入器。 * 任务号非法或缺省时返回一个**什么都不做**的写入器——调用方无需分支判断, * 生成链里不该出现 `if (progress)` 这种噪声。 */ export function openProgress(taskId: unknown, maxAttempts: number, now = Date.now()): ProgressWriter { if (!isValidTaskId(taskId)) { return NOOP_WRITER; } const snapshot: GenerationProgressSnapshot = { taskId, stage: 'queued', attempt: 0, maxAttempts, startedAt: now, updatedAt: now, note: '排队中', done: false, }; snapshots.set(taskId, snapshot); prune(now); return { update(patch) { const current = snapshots.get(taskId); if (!current) return; snapshots.set(taskId, { ...current, ...patch, updatedAt: Date.now() }); }, finish(ok, note) { const current = snapshots.get(taskId); if (!current) return; snapshots.set(taskId, { ...current, stage: ok ? 'done' : 'failed', done: true, ok, note, updatedAt: Date.now(), }); }, }; } export interface ProgressWriter { update(patch: Partial>): void; finish(ok: boolean, note: string): void; } /** 空写入器:没给任务号时用,所有调用都是空操作 */ const NOOP_WRITER: ProgressWriter = { update() { /* 无订阅者 */ }, finish() { /* 无订阅者 */ }, }; /** 读快照;不存在返回 null(客户端据此显示"任务已结束或已过期") */ export function readProgress(taskId: string): GenerationProgressSnapshot | null { if (!isValidTaskId(taskId)) return null; const snapshot = snapshots.get(taskId); if (!snapshot) return null; if (Date.now() - snapshot.updatedAt > TTL_MS) { snapshots.delete(taskId); return null; } return snapshot; } /** 过期与超量清理:进度是旁路,绝不能自己变成内存泄漏 */ function prune(now: number): void { for (const [id, snapshot] of snapshots) { if (now - snapshot.updatedAt > TTL_MS) snapshots.delete(id); } while (snapshots.size > MAX_TASKS) { const oldest = snapshots.keys().next(); if (oldest.done) break; snapshots.delete(oldest.value); } } /** 测试用 */ export function resetProgressStore(): void { snapshots.clear(); }