157 lines
10 KiB
TypeScript
157 lines
10 KiB
TypeScript
import { randomUUID } from 'crypto'
|
||
import sharp from 'sharp'
|
||
import { assertDetailGeneratorUser, DetailGeneratorError } from './access'
|
||
import { analyzeDetailFacts, composeDetailOutline, DetailProviderError, generateDetailImage, type DetailProviderImage } from './provider'
|
||
import { assertDetailDocumentAssets, checkpointDetailJob, claimDetailJob, findDetailAsset, findDetailProject, finishDetailJob, renewDetailJobLease, saveDetailJobAsset, saveDetailJobDocument, type StoredDetailJob } from './repository'
|
||
import { deleteDetailFile, readDetailFile, saveDetailFile } from './storage'
|
||
import { renderDetailExport } from './render'
|
||
import { type DetailDocument, type DetailExportFormat } from './types'
|
||
|
||
async function inputImages(document: DetailDocument, ownerId: string, projectId: string): Promise<DetailProviderImage[]> {
|
||
const ids = [...document.product.assetIds, ...document.product.referenceAssetIds].slice(0, 8)
|
||
const result: DetailProviderImage[] = []
|
||
for (const id of ids) {
|
||
const asset = await findDetailAsset(id, ownerId)
|
||
if (asset.projectId !== projectId || !['product', 'reference'].includes(asset.kind)) throw new DetailGeneratorError('模型输入文件不可访问', 403)
|
||
result.push({ buffer: await readDetailFile(asset.storageKey), mimeType: asset.mimeType })
|
||
}
|
||
return result
|
||
}
|
||
|
||
async function productText(document: DetailDocument, ownerId: string, projectId: string): Promise<string> {
|
||
const text = [document.product.facts]
|
||
for (const id of document.product.documentAssetIds) {
|
||
const asset = await findDetailAsset(id, ownerId)
|
||
if (asset.projectId !== projectId || asset.kind !== 'document') throw new DetailGeneratorError('产品资料不可访问', 403)
|
||
text.push(asset.extractedText || '')
|
||
}
|
||
const supplied = text.join('\n')
|
||
if (supplied.length > 60000) throw new DetailGeneratorError('产品资料合计超过 6 万字,请精简后再分析')
|
||
return supplied
|
||
}
|
||
|
||
export async function processDetailJob(current: StoredDetailJob): Promise<void> {
|
||
const token = current.leaseToken
|
||
if (!token) throw new Error('Job lease missing')
|
||
let checkpoint: Record<string, unknown> = { ...current.checkpoint }
|
||
let leaseLost = false
|
||
let heartbeatBusy = false
|
||
const heartbeat = setInterval(async () => {
|
||
if (heartbeatBusy) return
|
||
heartbeatBusy = true
|
||
try { if (!(await renewDetailJobLease(current.id, token))) leaseLost = true } catch { leaseLost = true } finally { heartbeatBusy = false }
|
||
}, 20000)
|
||
heartbeat.unref?.()
|
||
|
||
const persistCheckpoint = async (progress: number) => {
|
||
if (leaseLost) throw new DetailGeneratorError('任务执行租约已失效', 409, 'lease_lost')
|
||
await checkpointDetailJob(current.id, token, checkpoint, progress)
|
||
}
|
||
const dispatch = async <T>(stage: string, progress: number, call: (key: string) => Promise<T>): Promise<T> => {
|
||
if (leaseLost) throw new DetailGeneratorError('任务执行租约已失效', 409, 'lease_lost')
|
||
await assertDetailGeneratorUser(current.ownerId)
|
||
const key = `${current.id}-${stage}`
|
||
checkpoint = { ...checkpoint, stage, dispatchPending: true, dispatchKey: key }
|
||
await persistCheckpoint(progress)
|
||
try { await assertDetailGeneratorUser(current.ownerId) } catch (error) {
|
||
checkpoint = { ...checkpoint, dispatchPending: false }
|
||
await persistCheckpoint(progress)
|
||
throw error
|
||
}
|
||
try { return await call(key) } catch (error) {
|
||
if (error instanceof DetailProviderError && !error.outcomeUnknown || error instanceof DetailGeneratorError) {
|
||
checkpoint = { ...checkpoint, dispatchPending: false }
|
||
await persistCheckpoint(progress)
|
||
}
|
||
throw error
|
||
}
|
||
}
|
||
|
||
try {
|
||
await assertDetailGeneratorUser(current.ownerId)
|
||
const project = await findDetailProject(current.projectId, current.ownerId)
|
||
if (project.revision !== current.projectRevision) throw new DetailGeneratorError('项目版本发生变化,任务已停止', 409)
|
||
let document: DetailDocument = JSON.parse(JSON.stringify(project.document))
|
||
await assertDetailDocumentAssets(document, current.projectId, current.ownerId)
|
||
if (checkpoint.dispatchPending === true) throw new DetailProviderError('模型请求结果未知,任务已暂停;请核查服务商记录', true)
|
||
if (checkpoint.stage === 'complete') {
|
||
await finishDetailJob(current.id, token, 'succeeded', checkpoint)
|
||
return
|
||
}
|
||
|
||
if (current.kind === 'analyze') {
|
||
const images = await inputImages(document, current.ownerId, current.projectId)
|
||
const suppliedText = await productText(document, current.ownerId, current.projectId)
|
||
let facts = typeof checkpoint.facts === 'string' ? checkpoint.facts : null
|
||
if (!facts) {
|
||
facts = await dispatch('product-analysis', 10, key => analyzeDetailFacts(document, images, suppliedText, key))
|
||
checkpoint = { ...checkpoint, facts, stage: 'selling-points', dispatchPending: false }
|
||
await persistCheckpoint(40)
|
||
}
|
||
// Document text is included in the copy step as well as the multimodal analysis.
|
||
const analysis = await dispatch('outline', 45, key => composeDetailOutline(document, `${facts}\n用户原始资料:${suppliedText}`, key))
|
||
document = { ...document, analysis, approvedAt: null }
|
||
checkpoint = { ...checkpoint, stage: 'complete', dispatchPending: false }
|
||
await saveDetailJobDocument(current.id, token, document, checkpoint, 100)
|
||
} else if (current.kind === 'generate' || current.kind === 'regenerate') {
|
||
if (!document.approvedAt || !document.analysis) throw new DetailGeneratorError('卖点和大纲尚未确认')
|
||
const images = await inputImages(document, current.ownerId, current.projectId)
|
||
const target = current.kind === 'regenerate' ? document.screens.filter(screen => screen.id === current.payload.screenId) : document.screens
|
||
if (!target.length) throw new DetailGeneratorError('没有待生成屏幕')
|
||
const completed = new Set(Array.isArray(checkpoint.completedScreenIds) ? checkpoint.completedScreenIds as string[] : [])
|
||
for (let index = 0; index < target.length; index++) {
|
||
const screen = target[index]
|
||
if (completed.has(screen.id) || (current.kind === 'generate' && screen.image)) continue
|
||
const progress = Math.round(index / target.length * 90)
|
||
const buffer = await dispatch(`screen-${screen.id}`, progress, key => generateDetailImage(document, screen.imagePrompt, images, key))
|
||
const metadata = await sharp(buffer).metadata()
|
||
const assetId = randomUUID()
|
||
const storageKey = await saveDetailFile(current.projectId, buffer, 'png')
|
||
const area = screen.imageArea || { x: 0, y: 380, width: 750, height: Math.min(750, screen.height - 380) }
|
||
const replaced = { ...screen, image: { assetId, ...area } }
|
||
document = { ...document, screens: document.screens.map(value => value.id === screen.id ? replaced : value) }
|
||
completed.add(screen.id)
|
||
const receivedCheckpoint = { ...checkpoint, completedScreenIds: Array.from(completed), stage: 'rendering', dispatchPending: false }
|
||
try {
|
||
await saveDetailJobAsset(current.id, token, { id: assetId, projectId: current.projectId, ownerId: current.ownerId, kind: 'generated', name: `${index + 1}-${screen.title}.png`, mimeType: 'image/png', storageKey, byteSize: buffer.length, width: metadata.width, height: metadata.height }, document, receivedCheckpoint, Math.round((index + 1) / target.length * 95))
|
||
checkpoint = receivedCheckpoint
|
||
} catch (error) { await deleteDetailFile(storageKey).catch(() => undefined); throw error }
|
||
}
|
||
} else if (current.kind === 'export') {
|
||
const assets: Record<string, DetailProviderImage> = {}
|
||
const required = new Set(document.screens.flatMap(screen => [screen.image?.assetId, screen.logo?.assetId].filter((id): id is string => Boolean(id))))
|
||
for (const id of Array.from(required)) {
|
||
const asset = await findDetailAsset(id, current.ownerId)
|
||
if (asset.projectId !== current.projectId) throw new DetailGeneratorError('导出图片不可访问', 403)
|
||
assets[id] = { buffer: await readDetailFile(asset.storageKey), mimeType: asset.mimeType }
|
||
}
|
||
checkpoint = { ...checkpoint, stage: 'export' }; await persistCheckpoint(10)
|
||
await assertDetailGeneratorUser(current.ownerId)
|
||
const output = await renderDetailExport(document, assets, current.payload.format as DetailExportFormat)
|
||
const extension = output.filename.split('.').pop() || 'bin'
|
||
const storageKey = await saveDetailFile(current.projectId, output.buffer, extension)
|
||
const id = randomUUID()
|
||
const received = { ...checkpoint, stage: 'complete', exportAssetId: id, dispatchPending: false }
|
||
try {
|
||
await saveDetailJobAsset(current.id, token, { id, projectId: current.projectId, ownerId: current.ownerId, kind: 'export', name: output.filename, mimeType: output.mimeType, storageKey, byteSize: output.buffer.length }, null, received, 100)
|
||
checkpoint = received
|
||
} catch (error) { await deleteDetailFile(storageKey).catch(() => undefined); throw error }
|
||
}
|
||
checkpoint = { ...checkpoint, stage: 'complete', dispatchPending: false }
|
||
await finishDetailJob(current.id, token, 'succeeded', checkpoint)
|
||
} catch (error) {
|
||
const unknown = error instanceof DetailProviderError ? error.outcomeUnknown : checkpoint.dispatchPending === true
|
||
const safeMessage = error instanceof DetailProviderError || error instanceof DetailGeneratorError ? error.message : unknown ? '模型结果尚未确认,请核查请求记录,勿重复提交' : '任务执行失败,请检查输入资料或服务端配置'
|
||
const requestId = error instanceof DetailProviderError ? error.requestId : null
|
||
await finishDetailJob(current.id, token, unknown ? 'unknown' : 'failed', checkpoint, safeMessage, requestId)
|
||
} finally { clearInterval(heartbeat) }
|
||
}
|
||
|
||
export async function runDetailGeneratorWorkerOnce(): Promise<boolean> {
|
||
if (process.env.DETAIL_GENERATOR_ENABLED !== 'true') return false
|
||
const claimed = await claimDetailJob()
|
||
if (!claimed) return false
|
||
await processDetailJob(claimed)
|
||
return true
|
||
}
|