112 lines
9.5 KiB
TypeScript
112 lines
9.5 KiB
TypeScript
import { randomUUID } from 'crypto'
|
||
import sharp from 'sharp'
|
||
import { assertDetailGeneratorUser, DetailGeneratorError } from './access'
|
||
import { DETAIL_MAX_SCREENS, type DetailAnalysis, type DetailDocument } from './types'
|
||
import { captureAiModelBindings, getAiModelRuntimeState, resolveAiModel } from '@/lib/ai-model-service'
|
||
import { AiModelProtocolError, callAiModelProtocol } from '@/lib/ai-model-protocol'
|
||
import type { AiModelBinding, AiModelStage } from '@/lib/ai-model-types'
|
||
import { assertAiModelAdmin } from '@/lib/ai-model-access'
|
||
import { detailAnalysisSchema } from './validation'
|
||
import { DETAIL_COPY_FACTUAL_RULES, detailBrandPromptData, detailFactsPrompt, detailProductPromptData, mergeDetailFactWarnings, parseDetailFactsOutput } from './text-contract'
|
||
|
||
export interface DetailProviderImage { buffer: Buffer; mimeType: string }
|
||
export class DetailProviderError extends Error {
|
||
constructor(message: string, readonly outcomeUnknown: boolean, readonly requestId: string | null = null, readonly taskId: string | null = null, readonly taskPending = false) { super(message); this.name = 'DetailProviderError' }
|
||
}
|
||
|
||
export async function getDetailModels(): Promise<{ analysis: string[]; copy: string[]; image: string[] }> {
|
||
return (await getAiModelRuntimeState()).models
|
||
}
|
||
|
||
export async function assertDetailModels(models: DetailDocument['models']): Promise<void> {
|
||
await captureAiModelBindings(models)
|
||
}
|
||
|
||
export async function isDetailProviderConfigured(): Promise<boolean> { return (await getAiModelRuntimeState()).configured }
|
||
|
||
function requestIdFrom(response: Response, data: any, apiKey?: string): string | null {
|
||
const value = response.headers.get('x-request-id') || data?.request_id || data?.error?.request_id
|
||
return typeof value === 'string' && /^[\w-]{1,120}$/.test(value) && (!apiKey || !value.includes(apiKey)) ? value : null
|
||
}
|
||
|
||
async function callProvider(stage: AiModelStage, selection: string, prompt: string, images: DetailProviderImage[], idempotencyKey: string, ownerId: string, expected?: AiModelBinding, execution?: { onTaskSubmitted?: (taskId: string) => Promise<void>; resumeTaskId?: string; resumeRequestId?: string }) {
|
||
await assertDetailGeneratorUser(ownerId)
|
||
const { apiKey, binding, protocol } = await resolveAiModel(stage, selection, expected)
|
||
try {
|
||
return await callAiModelProtocol({ baseUrl: binding.baseUrl, apiKey, modelId: binding.modelId, protocol, prompt, images,
|
||
idempotencyKey, maxOutputTokens: stage === 'copy' ? 8000 : 3000, ...execution,
|
||
beforeRequest: async () => {
|
||
await resolveAiModel(stage, selection, binding)
|
||
await assertDetailGeneratorUser(ownerId)
|
||
await assertAiModelAdmin(ownerId)
|
||
} })
|
||
} catch (error) {
|
||
if (error instanceof DetailProviderError || error instanceof DetailGeneratorError) throw error
|
||
const upstream = error as { outcomeUnknown?: boolean; requestId?: string | null; taskId?: string | null; taskPending?: boolean }
|
||
const safeProtocolMessage = error instanceof AiModelProtocolError && !error.message.includes(apiKey) && error.message.length <= 500 ? error.message : null
|
||
const message = safeProtocolMessage || (execution?.resumeRequestId ? upstream.outcomeUnknown === false ? '原模型调用已结束,但未返回完整可用的文字结果' : '原请求结果尚未取回;未留存不代表失败,请核查原请求,勿重复提交' : upstream.outcomeUnknown === false ? '模型服务拒绝或未完成当前请求' : '模型请求结果未知,已暂停;请核查服务商请求记录,勿重复提交')
|
||
throw new DetailProviderError(message, upstream.outcomeUnknown !== false, upstream.requestId || execution?.resumeRequestId || null, upstream.taskId || null, upstream.taskPending === true)
|
||
}
|
||
}
|
||
|
||
export async function readDetailResponseBytes(response: Response, maxBytes: number): Promise<Buffer> {
|
||
if (Number(response.headers.get('content-length') || 0) > maxBytes) throw new Error('响应过大')
|
||
if (!response.body) throw new Error('响应为空')
|
||
const reader = response.body.getReader()
|
||
const chunks: Uint8Array[] = []
|
||
let total = 0
|
||
try {
|
||
while (true) {
|
||
const { done, value } = await reader.read()
|
||
if (done) break
|
||
total += value.byteLength
|
||
if (total > maxBytes) throw new Error('响应过大')
|
||
chunks.push(value)
|
||
}
|
||
return Buffer.concat(chunks, total)
|
||
} catch (error) { await reader.cancel().catch(() => undefined); throw error } finally { reader.releaseLock() }
|
||
}
|
||
|
||
async function imageContent(images: DetailProviderImage[]) {
|
||
return Promise.all(images.slice(0, 8).map(async (image) => ({
|
||
type: 'image_url', image_url: { url: `data:image/jpeg;base64,${(await sharp(image.buffer, { limitInputPixels: 40_000_000 }).rotate().resize(1024, 1024, { fit: 'inside', withoutEnlargement: true }).flatten({ background: '#ffffff' }).jpeg({ quality: 85 }).toBuffer()).toString('base64')}` },
|
||
})))
|
||
}
|
||
|
||
export async function analyzeDetailFacts(document: DetailDocument, images: DetailProviderImage[], extractedText: string, idempotencyKey: string, ownerId: string, expected?: AiModelBinding, execution?: { resumeRequestId: string }): Promise<string> {
|
||
const prompt = detailFactsPrompt(document.product, extractedText)
|
||
const data = await callProvider('analysis', document.models.analysis, prompt, images, idempotencyKey, ownerId, expected, execution)
|
||
try { return JSON.stringify(parseDetailFactsOutput(data.text)) } catch { throw new DetailProviderError('模型未返回有效的产品事实和待确认项,请检查分析结果', false, data.requestId) }
|
||
}
|
||
|
||
export async function composeDetailOutline(document: DetailDocument, facts: string, idempotencyKey: string, ownerId: string, expected?: AiModelBinding, sourceText = '', execution?: { resumeRequestId: string }): Promise<DetailAnalysis> {
|
||
let analyzed: ReturnType<typeof parseDetailFactsOutput>
|
||
try { analyzed = parseDetailFactsOutput(facts) } catch { throw new DetailProviderError(execution?.resumeRequestId ? '产品事实检查点无效,原文案请求仍待核查;勿重复提交' : '产品事实检查点无效,已停止文案调用', Boolean(execution?.resumeRequestId), execution?.resumeRequestId || null) }
|
||
const prompt = `根据给出的真实产品资料提炼卖点和电商详情页大纲,不添加未经提供的参数、认证、性能或促销承诺。缺少的信息写入 warnings,不用于卖点。每屏标题和正文使用中文;imagePrompt 描述不含文字、标志的场景,保持产品结构和外观。品牌风格用于指导语气与视觉;正文只写产品内容,内部草案、审核及品牌认可状态不写入消费者文案。返回严格 JSON:{"sellingPoints":["..."],"warnings":["..."],"screens":[{"title":"...","body":"...","imagePrompt":"..."}]},最多${DETAIL_MAX_SCREENS}屏,建议4至6屏。\n产品资料:${JSON.stringify(detailProductPromptData(document.product))}\n分析:${facts}\n品牌风格:${JSON.stringify(detailBrandPromptData(document.brand))}`
|
||
const data = await callProvider('copy', document.models.copy, `${DETAIL_COPY_FACTUAL_RULES}\n${prompt}\n用户原始资料(仅供核对):${JSON.stringify(sourceText.slice(0, 60000))}`, [], idempotencyKey, ownerId, expected, execution)
|
||
const content = data.text
|
||
let value: any
|
||
try {
|
||
if (typeof content !== 'string') throw new Error('empty')
|
||
value = JSON.parse(content.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, ''))
|
||
if (Array.isArray(value?.screens)) value.screens = value.screens.map((screen: any) => ({ id: randomUUID(), title: screen.title, body: screen.body, imagePrompt: screen.imagePrompt }))
|
||
} catch { throw new DetailProviderError('模型未返回可用的大纲,请检查产品资料', false, data.requestId) }
|
||
const parsed = detailAnalysisSchema.safeParse(value)
|
||
if (!parsed.success) throw new DetailProviderError('模型返回的大纲格式不符合要求', false, data.requestId)
|
||
try { parsed.data.warnings = mergeDetailFactWarnings(analyzed, parsed.data.warnings) } catch { throw new DetailProviderError('大纲的待确认事项超过限制,请先核对产品资料', false, data.requestId) }
|
||
return parsed.data
|
||
}
|
||
|
||
export async function generateDetailImage(document: DetailDocument, prompt: string, images: DetailProviderImage[], idempotencyKey: string, ownerId: string, expected?: AiModelBinding, execution?: { onTaskSubmitted?: (taskId: string) => Promise<void>; resumeTaskId?: string }): Promise<Buffer> {
|
||
if (!images.length) throw new DetailGeneratorError('请先上传或关联产品图片')
|
||
const fullPrompt = `生成用于同一产品详情页的一张高质量场景图片。严格保持产品参考中的具体款式、颜色、结构和比例;不得替换为相似产品。全套品牌调性一致:${document.brand.tone || '简洁、真实、清晰的产品摄影'};配色:${document.brand.primaryColor}、${document.brand.backgroundColor}。图片中不要生成文字、价格、标志或水印。第一张是实际产品;其余为该产品或风格参考,不得将参考中的其他产品混入。\n${prompt}`
|
||
const data = await callProvider('image', document.models.image, fullPrompt, images, idempotencyKey, ownerId, expected, execution)
|
||
const buffer = data.image
|
||
if (!buffer) throw new DetailProviderError('模型未返回可保存的图片', false)
|
||
try {
|
||
const output = await sharp(buffer, { limitInputPixels: 40_000_000 }).rotate().resize(4096, 4096, { fit: 'inside', withoutEnlargement: true }).png().toBuffer()
|
||
if (output.length > 20 * 1024 * 1024) throw new Error('size')
|
||
return output
|
||
} catch { throw new DetailProviderError('模型返回的图片无法读取或超出导出限制', false) }
|
||
}
|