157 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}