125 lines
9.6 KiB
TypeScript
125 lines
9.6 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 { requestAiModel } from '@/lib/ai-model-http'
|
||
import type { AiModelBinding, AiModelStage } from '@/lib/ai-model-types'
|
||
import { assertAiModelAdmin } from '@/lib/ai-model-access'
|
||
import { detailAnalysisSchema } from './validation'
|
||
|
||
export interface DetailProviderImage { buffer: Buffer; mimeType: string }
|
||
export class DetailProviderError extends Error {
|
||
constructor(message: string, readonly outcomeUnknown: boolean, readonly requestId: string | null = null) { 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, endpoint: '/chat/completions' | '/images/edits', body: string | FormData, idempotencyKey: string, ownerId: string, expected?: AiModelBinding): Promise<any> {
|
||
await assertDetailGeneratorUser(ownerId)
|
||
const { apiKey, binding } = await resolveAiModel(stage, selection, expected)
|
||
if (typeof body === 'string') body = JSON.stringify({ ...JSON.parse(body), model: binding.modelId })
|
||
else body.set('model', binding.modelId)
|
||
try {
|
||
const response = await requestAiModel(binding.baseUrl, apiKey, endpoint, body, idempotencyKey, async () => {
|
||
await resolveAiModel(stage, selection, binding)
|
||
await assertDetailGeneratorUser(ownerId)
|
||
await assertAiModelAdmin(ownerId)
|
||
})
|
||
const bytes = await readDetailResponseBytes(response, 32 * 1024 * 1024)
|
||
let data: any
|
||
try { data = JSON.parse(bytes.toString('utf8')) } catch { throw new DetailProviderError('模型响应格式无法确认,请核查请求结果', true, requestIdFrom(response, null, apiKey)) }
|
||
const requestId = requestIdFrom(response, data, apiKey)
|
||
if (!response.ok) {
|
||
const unknown = response.status >= 500 || data?.error?.code === 'request_outcome_unknown' || data?.code === 'request_outcome_unknown'
|
||
throw new DetailProviderError(unknown ? '模型请求结果未知,已暂停;请核查服务商请求记录,勿重复提交' : `模型服务拒绝了请求(${response.status})`, unknown, requestId)
|
||
}
|
||
if (bytes.includes(Buffer.from(apiKey))) throw new DetailProviderError('模型响应含敏感内容,已暂停;请核查服务商请求记录', true, requestId)
|
||
return data
|
||
} catch (error) {
|
||
if (error instanceof DetailProviderError || error instanceof DetailGeneratorError) throw error
|
||
throw new DetailProviderError('模型请求结果未知,已暂停;请核查服务商请求记录,勿重复提交', 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): Promise<string> {
|
||
const prompt = `你是产品资料分析员。只提取用户明确提供的产品事实,图片只能判断可见外观,不得推测材质、尺寸、认证、价格或性能。识别冲突和缺失信息,不要编造。返回 JSON,字段 facts(事实字符串数组)、warnings(待确认字符串数组)。\n产品:${JSON.stringify(document.product)}\n用户资料:${extractedText.slice(0, 60000)}`
|
||
const data = await callProvider('analysis', document.models.analysis, '/chat/completions', JSON.stringify({ model: document.models.analysis, messages: [{ role: 'user', content: [{ type: 'text', text: prompt }, ...await imageContent(images)] }], temperature: 0.2, max_tokens: 3000 }), idempotencyKey, ownerId, expected)
|
||
const content = data?.choices?.[0]?.message?.content
|
||
if (typeof content !== 'string' || !content.trim()) throw new DetailProviderError('模型未返回可用的产品分析', false)
|
||
return content.slice(0, 30000)
|
||
}
|
||
|
||
export async function composeDetailOutline(document: DetailDocument, facts: string, idempotencyKey: string, ownerId: string, expected?: AiModelBinding): Promise<DetailAnalysis> {
|
||
const prompt = `根据给出的真实产品资料提炼卖点和电商详情页大纲,不添加未经提供的参数、认证、性能或促销承诺。缺少的信息写入 warnings,不用于卖点。每屏标题和正文使用中文;imagePrompt 描述不含文字、标志的场景,保持产品结构和外观。品牌是草案,不能声称已获品牌认可。返回严格 JSON:{"sellingPoints":["..."],"warnings":["..."],"screens":[{"title":"...","body":"...","imagePrompt":"..."}]},最多${DETAIL_MAX_SCREENS}屏,建议4至6屏。\n产品资料:${JSON.stringify(document.product)}\n分析:${facts}\n品牌风格草案:${JSON.stringify(document.brand)}`
|
||
const data = await callProvider('copy', document.models.copy, '/chat/completions', JSON.stringify({ model: document.models.copy, messages: [{ role: 'user', content: prompt }], temperature: 0.3, max_tokens: 8000 }), idempotencyKey, ownerId, expected)
|
||
const content = data?.choices?.[0]?.message?.content
|
||
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) }
|
||
const parsed = detailAnalysisSchema.safeParse(value)
|
||
if (!parsed.success) throw new DetailProviderError('模型返回的大纲格式不符合要求', false)
|
||
return parsed.data
|
||
}
|
||
|
||
export async function generateDetailImage(document: DetailDocument, prompt: string, images: DetailProviderImage[], idempotencyKey: string, ownerId: string, expected?: AiModelBinding): Promise<Buffer> {
|
||
if (!images.length) throw new DetailGeneratorError('请先上传或关联产品图片')
|
||
const body = new FormData()
|
||
body.set('model', document.models.image)
|
||
body.set('prompt', `生成用于同一产品详情页的一张高质量场景图片。严格保持产品参考中的具体款式、颜色、结构和比例;不得替换为相似产品。全套品牌调性一致:${document.brand.tone || '简洁、真实、清晰的产品摄影'};配色:${document.brand.primaryColor}、${document.brand.backgroundColor}。图片中不要生成文字、价格、标志或水印。第一张是实际产品;其余为该产品或风格参考,不得将参考中的其他产品混入。\n${prompt}`)
|
||
body.set('n', '1'); body.set('size', '1024x1024'); body.set('response_format', 'b64_json')
|
||
for (let index = 0; index < Math.min(images.length, 8); index++) {
|
||
const image = await sharp(images[index].buffer, { limitInputPixels: 40_000_000 }).rotate().resize(1536, 1536, { fit: 'inside', withoutEnlargement: true }).png().toBuffer()
|
||
body.append(images.length === 1 ? 'image' : 'image[]', new Blob([new Uint8Array(image)], { type: 'image/png' }), `reference-${index + 1}.png`)
|
||
}
|
||
const data = await callProvider('image', document.models.image, '/images/edits', body, idempotencyKey, ownerId, expected)
|
||
const encoded = data?.data?.[0]?.b64_json
|
||
if (typeof encoded !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length > 24 * 1024 * 1024) throw new DetailProviderError('模型未返回可保存的图片', false)
|
||
const buffer = Buffer.from(encoded, 'base64')
|
||
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) }
|
||
}
|