125 lines
9.6 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 { 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) }
}