Spaces:
Running
Running
| import fs from 'node:fs/promises'; | |
| import path from 'node:path'; | |
| import { hasSupportedImageMagic } from './http-utils.js'; | |
| import { DEFAULT_OPENAI_COMPATIBLE_BASE_URL, openAICompatibleConfig } from './provider-api.js'; | |
| import { CODEXMOBILE_GENERATED_ROOT } from './runtime-paths.js'; | |
| export const GENERATED_ROOT = CODEXMOBILE_GENERATED_ROOT; | |
| export const DEFAULT_IMAGE_BASE_URL = DEFAULT_OPENAI_COMPATIBLE_BASE_URL; | |
| export const DEFAULT_IMAGE_MODEL = 'gpt-image-2'; | |
| export const IMAGE_TIMEOUT_MS = Number(process.env.CODEXMOBILE_IMAGE_TIMEOUT_MS || 420000); | |
| export const IMAGE_MAX_ATTEMPTS = Math.max(1, Number(process.env.CODEXMOBILE_IMAGE_MAX_ATTEMPTS || 3)); | |
| export const IMAGE_FALLBACK_MAX_ATTEMPTS = Math.max(1, Number(process.env.CODEXMOBILE_IMAGE_FALLBACK_MAX_ATTEMPTS || 2)); | |
| export const IMAGE_RETRY_BASE_DELAY_MS = Number(process.env.CODEXMOBILE_IMAGE_RETRY_BASE_DELAY_MS || 1600); | |
| export const GENERATE_PATTERNS = [ | |
| /(?:生成|画|绘制|制作|设计|出图|创建|做一张|来一张).*(?:图片|图像|图|照片|海报|宣传图|壁纸|头像|插画|封面|logo)/i, | |
| /(?:图片|图像|图|照片|海报|宣传图|壁纸|头像|插画|封面|logo).*(?:生成|画|绘制|制作|设计|出图|创建|做)/i, | |
| /\b(?:generate|create|draw|make)\b.*\b(?:image|photo|picture|poster|wallpaper|avatar|logo)\b/i | |
| ]; | |
| export const EDIT_PATTERNS = [ | |
| /(?:修改|编辑|改|换|去掉|移除|添加|变成|修图|美化|上色|扩图|换背景|抠图)/i, | |
| /\b(?:edit|modify|change|remove|add|retouch|replace|background)\b/i | |
| ]; | |
| export function safeErrorMessage(error) { | |
| const message = String(error?.message || error || '图片生成失败').trim(); | |
| return message.replace(/Bearer\s+[A-Za-z0-9._~-]+/g, 'Bearer [hidden]'); | |
| } | |
| export function isTransientImageError(error) { | |
| const message = String(error?.message || error || '').toLowerCase(); | |
| return ( | |
| message.includes('stream disconnected') || | |
| message.includes('before completion') || | |
| message.includes('requesttimeout') || | |
| message.includes('timeout') || | |
| message.includes('context canceled') || | |
| message.includes('internal_server_error') || | |
| message.includes('fetch failed') || | |
| message.includes('econnreset') || | |
| message.includes('socket hang up') || | |
| message.includes('502') || | |
| message.includes('503') || | |
| message.includes('504') | |
| ); | |
| } | |
| export function sleep(ms) { | |
| return new Promise((resolve) => setTimeout(resolve, ms)); | |
| } | |
| export function imageMimeToExt(mimeType) { | |
| const mime = String(mimeType || '').toLowerCase(); | |
| if (mime.includes('jpeg') || mime.includes('jpg')) { | |
| return 'jpg'; | |
| } | |
| if (mime.includes('webp')) { | |
| return 'webp'; | |
| } | |
| return 'png'; | |
| } | |
| export function parseDataUrl(value) { | |
| const match = String(value || '').match(/^data:([^;,]+);base64,(.+)$/s); | |
| if (!match) { | |
| return null; | |
| } | |
| return { | |
| mimeType: match[1] || 'image/png', | |
| b64: match[2] | |
| }; | |
| } | |
| export function detectImageIntent(message, attachments) { | |
| const text = String(message || '').trim(); | |
| const imageAttachments = attachments.filter((attachment) => attachment.kind === 'image'); | |
| if (imageAttachments.length && EDIT_PATTERNS.some((pattern) => pattern.test(text))) { | |
| return 'edit'; | |
| } | |
| if (GENERATE_PATTERNS.some((pattern) => pattern.test(text))) { | |
| return imageAttachments.length ? 'edit' : 'generate'; | |
| } | |
| if (imageAttachments.length && /(生成|出图|重绘|做成|变成|generate|create|draw|make)/i.test(text)) { | |
| return 'edit'; | |
| } | |
| return null; | |
| } | |
| export function isImageRequest(message, attachments = []) { | |
| return Boolean(detectImageIntent(message, attachments)); | |
| } | |
| export async function imageApiConfig(config = {}) { | |
| const providerConfig = await openAICompatibleConfig({ | |
| baseUrl: process.env.CODEXMOBILE_IMAGE_BASE_URL || config.baseUrl, | |
| defaultBaseUrl: DEFAULT_IMAGE_BASE_URL, | |
| apiKeys: [process.env.CODEXMOBILE_IMAGE_API_KEY] | |
| }); | |
| return { | |
| ...providerConfig, | |
| model: process.env.CODEXMOBILE_IMAGE_MODEL || DEFAULT_IMAGE_MODEL | |
| }; | |
| } | |
| export async function readImageAttachment(attachment) { | |
| const data = await fs.readFile(attachment.path); | |
| const mimeType = attachment.mimeType || 'image/png'; | |
| if (!hasSupportedImageMagic(data, mimeType)) { | |
| throw new Error('invalid_image_attachment'); | |
| } | |
| return { | |
| name: attachment.name || path.basename(attachment.path), | |
| mimeType, | |
| data | |
| }; | |
| } | |
| export function canFallbackEditToGeneration({ intent, attachments, error }) { | |
| return intent === 'edit' && attachments.some((attachment) => attachment.kind === 'image') && isTransientImageError(error); | |
| } | |
| export function buildFallbackGenerationPrompt(prompt, attachments, error) { | |
| const imageNames = attachments | |
| .filter((attachment) => attachment.kind === 'image') | |
| .map((attachment) => attachment.name) | |
| .filter(Boolean) | |
| .join(', '); | |
| const reason = safeErrorMessage(error); | |
| return [ | |
| 'Create a new image as a fallback because the image editing API disconnected.', | |
| imageNames ? `The user attached reference image file(s): ${imageNames}. Use the user request as the main visual direction.` : '', | |
| `User request: ${prompt}`, | |
| `Previous edit error: ${reason}`, | |
| 'Do not mention the technical failure in the image. Produce a polished final image that best matches the request.' | |
| ].filter(Boolean).join('\n'); | |
| } | |
| export async function requestImageGeneration({ prompt, attachments, config, forceGenerate = false }) { | |
| const intent = forceGenerate ? 'generate' : detectImageIntent(prompt, attachments) || 'generate'; | |
| const imageConfig = await imageApiConfig(config); | |
| const imageAttachments = forceGenerate ? [] : attachments.filter((attachment) => attachment.kind === 'image'); | |
| const imageFiles = []; | |
| for (const attachment of imageAttachments) { | |
| imageFiles.push(await readImageAttachment(attachment)); | |
| } | |
| async function sendRequest(apiKey) { | |
| const headers = apiKey ? { authorization: `Bearer ${apiKey}` } : {}; | |
| const timeoutSignal = AbortSignal.timeout(IMAGE_TIMEOUT_MS); | |
| if (intent === 'edit' && imageFiles.length) { | |
| const form = new FormData(); | |
| form.append('prompt', prompt); | |
| form.append('model', imageConfig.model); | |
| form.append('response_format', 'b64_json'); | |
| form.append('size', '1024x1024'); | |
| for (const image of imageFiles) { | |
| form.append('image', new Blob([image.data], { type: image.mimeType }), image.name); | |
| } | |
| return fetch(`${imageConfig.baseUrl}/images/edits`, { | |
| method: 'POST', | |
| headers, | |
| body: form, | |
| signal: timeoutSignal | |
| }); | |
| } | |
| return fetch(`${imageConfig.baseUrl}/images/generations`, { | |
| method: 'POST', | |
| headers: { | |
| 'content-type': 'application/json', | |
| ...headers | |
| }, | |
| body: JSON.stringify({ | |
| model: imageConfig.model, | |
| prompt, | |
| n: 1, | |
| size: '1024x1024', | |
| response_format: 'b64_json' | |
| }), | |
| signal: timeoutSignal | |
| }); | |
| } | |
| const apiKeys = imageConfig.apiKeys.length ? imageConfig.apiKeys : ['']; | |
| let response = null; | |
| let text = ''; | |
| let lastError = null; | |
| for (let index = 0; index < apiKeys.length; index += 1) { | |
| response = await sendRequest(apiKeys[index]); | |
| text = await response.text(); | |
| if (response.ok) { | |
| break; | |
| } | |
| let errorMessage = text; | |
| try { | |
| const parsed = text ? JSON.parse(text) : null; | |
| errorMessage = parsed?.error?.message || parsed?.error || parsed?.message || text; | |
| } catch { | |
| // Keep the raw text. | |
| } | |
| lastError = new Error(errorMessage || `图片接口返回 ${response.status}`); | |
| const invalidKey = /invalid api key|incorrect api key|unauthorized|401/i.test(errorMessage || '') || response.status === 401; | |
| if (!invalidKey || index === apiKeys.length - 1) { | |
| break; | |
| } | |
| console.warn(`[image] API key #${index + 1} failed, trying next key.`); | |
| } | |
| let data = null; | |
| try { | |
| data = text ? JSON.parse(text) : null; | |
| } catch { | |
| data = null; | |
| } | |
| if (!response.ok) { | |
| const message = data?.error?.message || data?.message || lastError?.message || text || `图片接口返回 ${response.status}`; | |
| throw new Error(message); | |
| } | |
| const items = Array.isArray(data?.data) ? data.data : []; | |
| const images = items | |
| .map((item) => { | |
| if (item.b64_json) { | |
| return { | |
| b64: item.b64_json, | |
| mimeType: data?.output_format ? `image/${data.output_format}` : 'image/png', | |
| revisedPrompt: item.revised_prompt || '' | |
| }; | |
| } | |
| const parsed = parseDataUrl(item.url); | |
| return parsed ? { ...parsed, revisedPrompt: item.revised_prompt || '' } : null; | |
| }) | |
| .filter(Boolean); | |
| if (!images.length) { | |
| throw new Error('图片接口没有返回图片内容'); | |
| } | |
| return { | |
| intent, | |
| model: imageConfig.model, | |
| images, | |
| usage: data?.usage || null | |
| }; | |
| } | |